refactor: rewrite project from Rust to Go

- Replace Axum with Chi router
- Replace sqlx with pgx for PostgreSQL
- Replace tonic/prost with grpc-go
- Replace tracing with zerolog
- Update flake.nix for Go build with protoc generation
- Preserve all existing endpoints and functionality

Stack: Chi, pgx, grpc-go, zerolog, yaml.v3
This commit is contained in:
Alexander
2026-04-29 10:45:05 +02:00
parent f24543f401
commit 41fb033d30
48 changed files with 2306 additions and 6652 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
/target
/result /result
.direnv/ .direnv/
config.yaml config.yaml
/server
/vendor
pkg/metadatapb/
+1 -1
View File
@@ -1 +1 @@
/nix/store/ykac3kn52hv5lqhffvg55zghgrvlgd0r-pre-commit-config.json /nix/store/mchzk3cbvp456fd3nbajm120nrry3pls-pre-commit-config.json
Generated
-3445
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
[package]
name = "music-agregator"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "music-agregator"
path = "src/main.rs"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "rustls-tls", "multipart"] }
async-trait = "0.1"
thiserror = "2"
url = "2"
roxmltree = "0.20"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
tonic = "0.12"
prost = "0.13"
[build-dependencies]
tonic-build = "0.12"
[profile.release]
opt-level = 3
lto = true
-7
View File
@@ -1,7 +0,0 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(false)
.build_client(true)
.compile_protos(&["proto/metadata/v1/metadata.proto"], &["proto"])?;
Ok(())
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/fujin/music-agregator/internal/api"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/fujin/music-agregator/internal/services"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
configPath := flag.String("c", "config.yaml", "path to config file")
port := flag.Int("p", 0, "port to listen on (overrides config)")
flag.Parse()
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatal().Err(err).Msg("failed to load config")
}
if *port != 0 {
cfg.App.Port = *port
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
indexerService, err := services.NewIndexerService(cfg.Indexers)
if err != nil {
log.Fatal().Err(err).Msg("failed to create indexer service")
}
log.Info().Int("count", len(cfg.Indexers)).Msg("initialized indexer service")
torrentService, err := services.NewTorrentService(cfg.Torrent)
if err != nil {
log.Fatal().Err(err).Msg("failed to create torrent service")
}
if torrentService.IsConfigured() {
if err := torrentService.Connect(ctx); err != nil {
log.Warn().Err(err).Msg("failed to connect to torrent client")
} else {
log.Info().Str("type", string(cfg.Torrent.ClientType)).Msg("connected to torrent client")
}
} else {
log.Warn().Msg("no torrent client configured")
}
metadataClient, err := metadata.NewClient(cfg.Metadata.Endpoint)
if err != nil {
log.Fatal().Err(err).Msg("failed to create metadata client")
}
log.Info().Str("endpoint", cfg.Metadata.Endpoint).Msg("initialized metadata client")
var db *database.DB
if cfg.Database.URL != "" {
db, err = database.New(ctx, cfg.Database.URL)
if err != nil {
log.Warn().Err(err).Msg("failed to connect to database (continuing without db)")
} else {
log.Info().Msg("connected to database")
}
}
handlers := &api.Handlers{
IndexerService: indexerService,
TorrentService: torrentService,
MetadataClient: metadataClient,
DB: db,
}
router := api.NewRouter(handlers)
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.App.Port),
Handler: router,
}
go func() {
log.Info().Int("port", cfg.App.Port).Msg("starting server")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("server error")
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info().Msg("shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Error().Err(err).Msg("server forced to shutdown")
}
if db != nil {
db.Close()
}
if torrentService.IsConfigured() {
torrentService.Disconnect(context.Background())
}
metadataClient.Close()
log.Info().Msg("server stopped")
}
+33 -8
View File
@@ -33,16 +33,39 @@
src = ./.; src = ./.;
hooks = { hooks = {
nixfmt.enable = true; nixfmt.enable = true;
rustfmt.enable = true; gofmt.enable = true;
}; };
}; };
music-agregator = pkgs.rustPlatform.buildRustPackage { music-agregator = pkgs.buildGoModule {
pname = "music-agregator"; pname = "music-agregator";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
cargoLock.lockFile = ./Cargo.lock; vendorHash = "sha256-gad5/pLGWyU45QiEvZJ8xEKNy4K2p5OykKE0nykzh8w=";
nativeBuildInputs = [ pkgs.protobuf ];
nativeBuildInputs = [
pkgs.protobuf
pkgs.protoc-gen-go
pkgs.protoc-gen-go-grpc
];
preBuild = ''
export HOME=$(mktemp -d)
mkdir -p pkg/metadatapb/metadata/v1
${pkgs.protobuf}/bin/protoc \
--plugin=protoc-gen-go=${pkgs.protoc-gen-go}/bin/protoc-gen-go \
--plugin=protoc-gen-go-grpc=${pkgs.protoc-gen-go-grpc}/bin/protoc-gen-go-grpc \
--proto_path=proto \
--go_out=pkg/metadatapb --go_opt=paths=source_relative \
--go-grpc_out=pkg/metadatapb --go-grpc_opt=paths=source_relative \
proto/metadata/v1/metadata.proto
'';
subPackages = [ "cmd/server" ];
postInstall = ''
mv $out/bin/server $out/bin/music-agregator
'';
}; };
in in
{ {
@@ -65,11 +88,13 @@
gitleaks gitleaks
plantuml plantuml
protobuf protobuf
protoc-gen-go
protoc-gen-go-grpc
rustc go
cargo gopls
rustfmt gotools
clippy go-tools
]; ];
}; };
}; };
+30
View File
@@ -0,0 +1,30 @@
module github.com/fujin/music-agregator
go 1.23
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.6.0
github.com/rs/zerolog v1.33.0
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b // indirect
)
+70
View File
@@ -0,0 +1,70 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b h1:04+jVzTs2XBnOZcPsLnmrTGqltqJbZQ1Ey26hjYdQQ0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+280
View File
@@ -0,0 +1,280 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/fujin/music-agregator/internal/services"
"github.com/go-chi/chi/v5"
)
type Handlers struct {
IndexerService *services.IndexerService
TorrentService *services.TorrentService
MetadataClient *metadata.Client
DB *database.DB
}
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) {
indexers := h.IndexerService.GetIndexers(r.Context())
writeJSON(w, http.StatusOK, indexers)
}
type searchRequest struct {
Artist string `json:"artist"`
Album *string `json:"album,omitempty"`
Year *uint32 `json:"year,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) {
var req searchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Limit == 0 {
req.Limit = 20
}
criteria := &indexer.MusicSearchCriteria{
Artist: req.Artist,
Album: req.Album,
Year: req.Year,
Limit: req.Limit,
Offset: req.Offset,
}
results, err := h.IndexerService.Search(r.Context(), criteria, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, results)
}
func (h *Handlers) ListTorrents(w http.ResponseWriter, r *http.Request) {
torrents, err := h.TorrentService.ListTorrents(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, torrents)
}
func (h *Handlers) GetTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
torrent, err := h.TorrentService.GetTorrent(r.Context(), hash)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, torrent)
}
type addTorrentRequest struct {
URL string `json:"url"`
SavePath *string `json:"save_path,omitempty"`
}
func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) {
var req addTorrentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.TorrentService.AddTorrentURL(r.Context(), req.URL, req.SavePath); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "added"})
}
type removeTorrentRequest struct {
DeleteFiles bool `json:"delete_files"`
}
func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
var req removeTorrentRequest
json.NewDecoder(r.Body).Decode(&req)
if err := h.TorrentService.RemoveTorrent(r.Context(), hash, req.DeleteFiles); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}
func (h *Handlers) PauseTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if err := h.TorrentService.PauseTorrent(r.Context(), hash); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "paused"})
}
func (h *Handlers) ResumeTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if err := h.TorrentService.ResumeTorrent(r.Context(), hash); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"})
}
func (h *Handlers) SearchArtists(w http.ResponseWriter, r *http.Request) {
var req struct {
Query string `json:"query"`
Limit int32 `json:"limit,omitempty"`
Offset int32 `json:"offset,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Limit == 0 {
req.Limit = 10
}
result, err := h.MetadataClient.SearchArtists(r.Context(), req.Query, req.Limit, req.Offset)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) GetArtistAlbums(w http.ResponseWriter, r *http.Request) {
artistID := chi.URLParam(r, "id")
result, err := h.MetadataClient.GetArtistAlbums(r.Context(), artistID, 500, 0)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) Sync(w http.ResponseWriter, r *http.Request) {
var req struct {
Artist string `json:"artist"`
Album *string `json:"album,omitempty"`
Download *bool `json:"download,omitempty"`
Store *bool `json:"store,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
download := true
if req.Download != nil {
download = *req.Download
}
store := true
if req.Store != nil {
store = *req.Store
}
options := services.SyncOptions{
Artist: req.Artist,
Album: req.Album,
Download: download,
Store: store,
}
result, err := services.Sync(r.Context(), options, h.MetadataClient, h.IndexerService, h.TorrentService, h.DB)
if err != nil {
if _, ok := err.(*services.NotFoundError); ok {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) ListLibraryArtists(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artists, err := h.DB.ListArtists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, artists)
}
func (h *Handlers) ListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
albums, err := h.DB.ListAllAlbums(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, albums)
}
func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistCount, err := h.DB.CountArtists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
albumCount, err := h.DB.CountAlbums(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{
"artists": artistCount,
"albums": albumCount,
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func NewRouter(h *Handlers) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Get("/health", h.Health)
r.Route("/api", func(r chi.Router) {
r.Route("/indexers", func(r chi.Router) {
r.Get("/", h.ListIndexers)
r.Post("/search", h.SearchIndexers)
})
r.Route("/torrents", func(r chi.Router) {
r.Get("/", h.ListTorrents)
r.Post("/", h.AddTorrent)
r.Get("/{hash}", h.GetTorrent)
r.Delete("/{hash}", h.RemoveTorrent)
r.Post("/{hash}/pause", h.PauseTorrent)
r.Post("/{hash}/resume", h.ResumeTorrent)
})
r.Route("/metadata", func(r chi.Router) {
r.Post("/artists/search", h.SearchArtists)
r.Get("/artists/{id}/albums", h.GetArtistAlbums)
})
r.Post("/sync", h.Sync)
r.Route("/library", func(r chi.Router) {
r.Get("/artists", h.ListLibraryArtists)
r.Get("/albums", h.ListLibraryAlbums)
r.Get("/stats", h.LibraryStats)
})
})
return r
}
+92
View File
@@ -0,0 +1,92 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
App AppConfig `yaml:"app"`
Database DatabaseConfig `yaml:"database"`
Metadata MetadataConfig `yaml:"metadata"`
Indexers []IndexerConfig `yaml:"indexers"`
Torrent TorrentConfig `yaml:"torrent"`
}
type AppConfig struct {
Port int `yaml:"port"`
}
type DatabaseConfig struct {
URL string `yaml:"url"`
}
type MetadataConfig struct {
Endpoint string `yaml:"endpoint"`
}
type IndexerType string
const (
IndexerTypeJackett IndexerType = "jackett"
IndexerTypeProwlarr IndexerType = "prowlarr"
IndexerTypeTorznab IndexerType = "torznab"
)
type IndexerConfig struct {
Name string `yaml:"name"`
IndexerType IndexerType `yaml:"indexer_type"`
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
}
type TorrentClientType string
const (
TorrentClientQBittorrent TorrentClientType = "qbittorrent"
TorrentClientStub TorrentClientType = "stub"
TorrentClientNone TorrentClientType = "none"
)
type TorrentConfig struct {
ClientType TorrentClientType `yaml:"client_type"`
URL string `yaml:"url,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
LogPath string `yaml:"log_path,omitempty"`
SavePath string `yaml:"save_path,omitempty"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if cfg.App.Port == 0 {
cfg.App.Port = 3000
}
for i := range cfg.Indexers {
if cfg.Indexers[i].IndexerType == "" {
cfg.Indexers[i].IndexerType = IndexerTypeJackett
}
}
if cfg.Torrent.ClientType == "" {
cfg.Torrent.ClientType = TorrentClientNone
}
if cfg.Torrent.SavePath == "" {
cfg.Torrent.SavePath = "/tmp/downloads"
}
return &cfg, nil
}
+252
View File
@@ -0,0 +1,252 @@
package database
import (
"context"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
pool *pgxpool.Pool
}
func New(ctx context.Context, databaseURL string) (*DB, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
return nil, err
}
return &DB{pool: pool}, nil
}
func (db *DB) Close() {
db.pool.Close()
}
type Artist struct {
ID string
Name string
SortName string
ArtistType string
Description string
Genres []Genre
ExternalIDs []ExternalID
}
type Album struct {
ID string
Title string
AlbumType string
ReleaseDate string
Genres []Genre
}
type Genre struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ExternalID struct {
Source string `json:"source"`
SourceID string `json:"source_id"`
URL string `json:"url"`
}
type ArtistMetadataRow struct {
ID uuid.UUID `json:"id"`
ForeignArtistID *string `json:"foreign_artist_id"`
Name string `json:"name"`
SortName *string `json:"sort_name"`
ArtistType *string `json:"artist_type"`
Genres json.RawMessage `json:"genres"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AlbumRow struct {
ID uuid.UUID `json:"id"`
ArtistMetadataID uuid.UUID `json:"artist_metadata_id"`
ForeignAlbumID *string `json:"foreign_album_id"`
Title string `json:"title"`
AlbumType *string `json:"album_type"`
ReleaseDate *time.Time `json:"release_date"`
Monitored bool `json:"monitored"`
AddedAt time.Time `json:"added_at"`
}
type AlbumWithArtistRow struct {
ID uuid.UUID `json:"id"`
ForeignAlbumID *string `json:"foreign_album_id"`
Title string `json:"title"`
AlbumType *string `json:"album_type"`
ReleaseDate *time.Time `json:"release_date"`
Monitored bool `json:"monitored"`
AddedAt time.Time `json:"added_at"`
ArtistID uuid.UUID `json:"artist_id"`
ArtistName string `json:"artist_name"`
}
func (db *DB) UpsertArtistMetadata(ctx context.Context, artist *Artist) (uuid.UUID, error) {
id, err := uuid.Parse(artist.ID)
if err != nil {
id = uuid.New()
}
genres, _ := json.Marshal(artist.Genres)
links, _ := json.Marshal(artist.ExternalIDs)
var resultID uuid.UUID
err = db.pool.QueryRow(ctx, `
INSERT INTO artist_metadata (
id, foreign_artist_id, name, sort_name, disambiguation,
artist_type, status, overview, genres, links, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (foreign_artist_id) DO UPDATE SET
name = EXCLUDED.name,
sort_name = EXCLUDED.sort_name,
artist_type = EXCLUDED.artist_type,
overview = EXCLUDED.overview,
genres = EXCLUDED.genres,
links = EXCLUDED.links,
updated_at = NOW()
RETURNING id
`, id, artist.ID, artist.Name, artist.SortName, artist.Description,
artist.ArtistType, "active", artist.Description, genres, links).Scan(&resultID)
return resultID, err
}
var cleanTitleRegex = regexp.MustCompile(`[^a-z0-9]`)
func (db *DB) UpsertAlbum(ctx context.Context, album *Album, artistMetadataID uuid.UUID) (uuid.UUID, error) {
id, err := uuid.Parse(album.ID)
if err != nil {
id = uuid.New()
}
genres, _ := json.Marshal(album.Genres)
images, _ := json.Marshal([]any{})
var releaseDate *time.Time
if album.ReleaseDate != "" {
if t, err := time.Parse("2006-01-02", album.ReleaseDate); err == nil {
releaseDate = &t
}
}
cleanTitle := cleanTitleRegex.ReplaceAllString(strings.ToLower(album.Title), "")
var resultID uuid.UUID
err = db.pool.QueryRow(ctx, `
INSERT INTO albums (
id, artist_metadata_id, foreign_album_id, title, clean_title,
overview, album_type, release_date, images, genres
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (foreign_album_id) DO UPDATE SET
title = EXCLUDED.title,
album_type = EXCLUDED.album_type,
release_date = EXCLUDED.release_date,
genres = EXCLUDED.genres
RETURNING id
`, id, artistMetadataID, album.ID, album.Title, cleanTitle,
"", album.AlbumType, releaseDate, images, genres).Scan(&resultID)
return resultID, err
}
func (db *DB) ListArtists(ctx context.Context) ([]ArtistMetadataRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at
FROM artist_metadata
ORDER BY name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var artists []ArtistMetadataRow
for rows.Next() {
var a ArtistMetadataRow
err := rows.Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
artists = append(artists, a)
}
return artists, nil
}
func (db *DB) ListAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) ([]AlbumRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
FROM albums
WHERE artist_metadata_id = $1
ORDER BY release_date DESC NULLS LAST
`, artistMetadataID)
if err != nil {
return nil, err
}
defer rows.Close()
var albums []AlbumRow
for rows.Next() {
var a AlbumRow
err := rows.Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt)
if err != nil {
return nil, err
}
albums = append(albums, a)
}
return albums, nil
}
func (db *DB) ListAllAlbums(ctx context.Context) ([]AlbumWithArtistRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT
a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at,
am.id as artist_id, am.name as artist_name
FROM albums a
JOIN artist_metadata am ON a.artist_metadata_id = am.id
ORDER BY a.added_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var albums []AlbumWithArtistRow
for rows.Next() {
var a AlbumWithArtistRow
err := rows.Scan(&a.ID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistID, &a.ArtistName)
if err != nil {
return nil, err
}
albums = append(albums, a)
}
return albums, nil
}
func (db *DB) CountArtists(ctx context.Context) (int64, error) {
var count int64
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artist_metadata").Scan(&count)
return count, err
}
func (db *DB) CountAlbums(ctx context.Context) (int64, error) {
var count int64
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count)
return count, err
}
+54
View File
@@ -0,0 +1,54 @@
package indexer
import (
"regexp"
"strings"
)
type MusicSearchCriteria struct {
Artist string
Album *string
Year *uint32
Limit int
Offset int
}
func (c *MusicSearchCriteria) CleanArtist() string {
return cleanSearchTerm(c.Artist)
}
func (c *MusicSearchCriteria) CleanAlbum() *string {
if c.Album == nil {
return nil
}
cleaned := cleanSearchTerm(*c.Album)
return &cleaned
}
var cleanRegex = regexp.MustCompile(`[^\w\s]`)
func cleanSearchTerm(s string) string {
s = cleanRegex.ReplaceAllString(s, " ")
fields := strings.Fields(s)
return strings.Join(fields, " ")
}
type SearchResult struct {
GUID string `json:"guid"`
Title string `json:"title"`
DownloadURL string `json:"download_url"`
InfoURL *string `json:"info_url,omitempty"`
Size uint64 `json:"size"`
PublishDate *string `json:"publish_date,omitempty"`
Artist *string `json:"artist,omitempty"`
Album *string `json:"album,omitempty"`
Year *uint32 `json:"year,omitempty"`
Label *string `json:"label,omitempty"`
Seeders *int `json:"seeders,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Grabs *int `json:"grabs,omitempty"`
Infohash *string `json:"infohash,omitempty"`
MagnetURL *string `json:"magnet_url,omitempty"`
Indexer string `json:"indexer"`
Categories []uint32 `json:"categories"`
}
+289
View File
@@ -0,0 +1,289 @@
package indexer
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
var (
ErrAuthFailed = errors.New("authentication failed")
ErrSearchFailed = errors.New("search failed")
ErrRateLimited = errors.New("rate limited")
ErrUnavailable = errors.New("indexer unavailable")
ErrParseError = errors.New("parse error")
)
type TorznabIndexer struct {
name string
baseURL *url.URL
apiKey string
categories []uint32
client *http.Client
}
func NewTorznabIndexer(name, baseURL, apiKey string) (*TorznabIndexer, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
return &TorznabIndexer{
name: name,
baseURL: u,
apiKey: apiKey,
categories: []uint32{3000, 3010, 3040},
client: &http.Client{},
}, nil
}
func (i *TorznabIndexer) WithCategories(cats []uint32) *TorznabIndexer {
i.categories = cats
return i
}
func (i *TorznabIndexer) Name() string {
return i.name
}
func (i *TorznabIndexer) buildSearchURL(criteria *MusicSearchCriteria) string {
u := *i.baseURL
q := u.Query()
q.Set("t", "music")
q.Set("apikey", i.apiKey)
q.Set("extended", "1")
var cats []string
for _, c := range i.categories {
cats = append(cats, strconv.FormatUint(uint64(c), 10))
}
q.Set("cat", strings.Join(cats, ","))
var qParts []string
qParts = append(qParts, criteria.CleanArtist())
if album := criteria.CleanAlbum(); album != nil {
qParts = append(qParts, *album)
}
if criteria.Year != nil {
qParts = append(qParts, strconv.FormatUint(uint64(*criteria.Year), 10))
}
q.Set("q", strings.Join(qParts, " "))
q.Set("limit", strconv.Itoa(criteria.Limit))
q.Set("offset", strconv.Itoa(criteria.Offset))
u.RawQuery = q.Encode()
return u.String()
}
func (i *TorznabIndexer) Search(ctx context.Context, criteria *MusicSearchCriteria) ([]SearchResult, error) {
searchURL := i.buildSearchURL(criteria)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := i.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := 60
if ra := resp.Header.Get("Retry-After"); ra != "" {
if v, err := strconv.Atoi(ra); err == nil {
retryAfter = v
}
}
return nil, fmt.Errorf("%w: retry after %d seconds", ErrRateLimited, retryAfter)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return i.parseResponse(body)
}
func (i *TorznabIndexer) TestConnection(ctx context.Context) error {
u := *i.baseURL
q := u.Query()
q.Set("t", "caps")
q.Set("apikey", i.apiKey)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return err
}
resp, err := i.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
xmlStr := string(body)
if strings.Contains(xmlStr, "<error") && strings.Contains(xmlStr, `code="1`) {
return ErrAuthFailed
}
return nil
}
type rssResponse struct {
Channel struct {
Items []rssItem `xml:"item"`
} `xml:"channel"`
Error *rssError `xml:"error"`
}
type rssError struct {
Code string `xml:"code,attr"`
Description string `xml:"description,attr"`
}
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Link string `xml:"link"`
Comments string `xml:"comments"`
PubDate string `xml:"pubDate"`
Enclosure enclosure `xml:"enclosure"`
Attrs []attr `xml:"attr"`
}
type enclosure struct {
URL string `xml:"url,attr"`
Length string `xml:"length,attr"`
}
type attr struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
func (i *TorznabIndexer) parseResponse(data []byte) ([]SearchResult, error) {
var rss rssResponse
if err := xml.Unmarshal(data, &rss); err != nil {
return nil, fmt.Errorf("%w: %v", ErrParseError, err)
}
if rss.Error != nil {
if strings.HasPrefix(rss.Error.Code, "1") {
return nil, ErrAuthFailed
}
return nil, fmt.Errorf("%w: %s", ErrSearchFailed, rss.Error.Description)
}
var results []SearchResult
for _, item := range rss.Channel.Items {
result := i.parseItem(item)
results = append(results, result)
}
return results, nil
}
func (i *TorznabIndexer) parseItem(item rssItem) SearchResult {
attrs := make(map[string]string)
var categories []uint32
for _, a := range item.Attrs {
if a.Name == "category" {
if v, err := strconv.ParseUint(a.Value, 10, 32); err == nil {
categories = append(categories, uint32(v))
}
} else {
attrs[a.Name] = a.Value
}
}
size := uint64(0)
if s, ok := attrs["size"]; ok {
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
size = v
}
} else if item.Enclosure.Length != "" {
if v, err := strconv.ParseUint(item.Enclosure.Length, 10, 64); err == nil {
size = v
}
}
result := SearchResult{
GUID: item.GUID,
Title: item.Title,
DownloadURL: item.Link,
Size: size,
Indexer: i.name,
Categories: categories,
}
if item.Comments != "" {
result.InfoURL = &item.Comments
}
if item.PubDate != "" {
result.PublishDate = &item.PubDate
}
if v, ok := attrs["artist"]; ok {
result.Artist = &v
}
if v, ok := attrs["album"]; ok {
result.Album = &v
}
if v, ok := attrs["year"]; ok {
if y, err := strconv.ParseUint(v, 10, 32); err == nil {
y32 := uint32(y)
result.Year = &y32
}
}
if v, ok := attrs["label"]; ok {
result.Label = &v
}
if v, ok := attrs["seeders"]; ok {
if s, err := strconv.Atoi(v); err == nil {
result.Seeders = &s
}
}
if v, ok := attrs["leechers"]; ok {
if l, err := strconv.Atoi(v); err == nil {
result.Leechers = &l
}
}
if v, ok := attrs["grabs"]; ok {
if g, err := strconv.Atoi(v); err == nil {
result.Grabs = &g
}
}
if v, ok := attrs["infohash"]; ok {
result.Infohash = &v
}
if v, ok := attrs["magneturl"]; ok {
result.MagnetURL = &v
}
return result
}
+56
View File
@@ -0,0 +1,56 @@
package metadata
import (
"context"
"strings"
pb "github.com/fujin/music-agregator/pkg/metadatapb/metadata/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Client struct {
conn *grpc.ClientConn
client pb.MetadataServiceClient
}
func NewClient(endpoint string) (*Client, error) {
endpoint = strings.TrimPrefix(endpoint, "http://")
endpoint = strings.TrimPrefix(endpoint, "https://")
conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
return &Client{
conn: conn,
client: pb.NewMetadataServiceClient(conn),
}, nil
}
func (c *Client) Close() error {
return c.conn.Close()
}
func (c *Client) SearchArtists(ctx context.Context, query string, limit, offset int32) (*pb.SearchArtistsResponse, error) {
return c.client.SearchArtists(ctx, &pb.SearchArtistsRequest{
Query: query,
Limit: limit,
Offset: offset,
})
}
func (c *Client) GetArtist(ctx context.Context, id string) (*pb.Artist, error) {
return c.client.GetArtist(ctx, &pb.GetArtistRequest{
Identifier: &pb.GetArtistRequest_Id{Id: id},
})
}
func (c *Client) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int32) (*pb.GetArtistAlbumsResponse, error) {
return c.client.GetArtistAlbums(ctx, &pb.GetArtistAlbumsRequest{
ArtistId: artistID,
Limit: limit,
Offset: offset,
})
}
+298
View File
@@ -0,0 +1,298 @@
package services
import (
"context"
"strconv"
"strings"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/rs/zerolog/log"
)
type SyncOptions struct {
Artist string `json:"artist"`
Album *string `json:"album,omitempty"`
Download bool `json:"download"`
Store bool `json:"store"`
}
type SyncResult struct {
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
TotalAlbums int `json:"total_albums"`
AlbumsStored int `json:"albums_stored"`
AlbumsDownloaded int `json:"albums_downloaded"`
AlbumsNoResults int `json:"albums_no_results"`
AlbumsFailed int `json:"albums_failed"`
Results []AlbumSyncResult `json:"results,omitempty"`
}
type AlbumSyncResult struct {
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
Stored bool `json:"stored"`
DownloadStatus *DownloadStatus `json:"download_status,omitempty"`
TorrentHash *string `json:"torrent_hash,omitempty"`
Indexer *string `json:"indexer,omitempty"`
Error *string `json:"error,omitempty"`
}
type DownloadStatus string
const (
DownloadStatusAdded DownloadStatus = "added"
DownloadStatusNoResults DownloadStatus = "noresults"
DownloadStatusFailed DownloadStatus = "failed"
DownloadStatusSkipped DownloadStatus = "skipped"
)
type downloadResult struct {
status DownloadStatus
torrentHash *string
indexer *string
err *string
}
func Sync(
ctx context.Context,
options SyncOptions,
metadataClient *metadata.Client,
indexerService *IndexerService,
torrentService *TorrentService,
db *database.DB,
) (*SyncResult, error) {
searchResult, err := metadataClient.SearchArtists(ctx, options.Artist, 1, 0)
if err != nil {
return nil, err
}
if len(searchResult.Artists) == 0 {
return nil, &NotFoundError{Message: "artist not found: " + options.Artist}
}
artist := searchResult.Artists[0]
var artistMetadataID *string
if options.Store && db != nil {
dbArtist := &database.Artist{
ID: artist.Id,
Name: artist.Name,
SortName: artist.SortName,
ArtistType: artist.ArtistType,
Description: artist.Description,
}
for _, g := range artist.Genres {
dbArtist.Genres = append(dbArtist.Genres, database.Genre{ID: g.Id, Name: g.Name})
}
for _, e := range artist.ExternalIds {
dbArtist.ExternalIDs = append(dbArtist.ExternalIDs, database.ExternalID{
Source: e.Source,
SourceID: e.SourceId,
URL: e.Url,
})
}
id, err := db.UpsertArtistMetadata(ctx, dbArtist)
if err != nil {
log.Warn().Err(err).Str("artist", artist.Name).Msg("failed to store artist metadata")
} else {
idStr := id.String()
artistMetadataID = &idStr
log.Info().Str("artist", artist.Name).Str("id", idStr).Msg("stored artist metadata")
}
}
albumsResponse, err := metadataClient.GetArtistAlbums(ctx, artist.Id, 500, 0)
if err != nil {
return nil, err
}
var albumsToProcess = albumsResponse.Albums
if options.Album != nil {
filterLower := strings.ToLower(*options.Album)
var filtered = albumsToProcess[:0]
for _, a := range albumsToProcess {
if strings.Contains(strings.ToLower(a.Title), filterLower) {
filtered = append(filtered, a)
}
}
albumsToProcess = filtered
}
var results []AlbumSyncResult
var albumsStored, albumsDownloaded, albumsNoResults, albumsFailed int
for _, album := range albumsToProcess {
var stored bool
if options.Store && db != nil && artistMetadataID != nil {
dbAlbum := &database.Album{
ID: album.Id,
Title: album.Title,
AlbumType: album.AlbumType,
ReleaseDate: album.ReleaseDate,
}
for _, g := range album.Genres {
dbAlbum.Genres = append(dbAlbum.Genres, database.Genre{ID: g.Id, Name: g.Name})
}
id, err := parseUUID(*artistMetadataID)
if err == nil {
if _, err := db.UpsertAlbum(ctx, dbAlbum, id); err != nil {
log.Warn().Err(err).Str("album", album.Title).Msg("failed to store album")
} else {
albumsStored++
stored = true
}
}
}
var downloadStatus *DownloadStatus
var torrentHash, indexerName, dlError *string
if options.Download {
var year *uint32
if album.ReleaseDate != "" {
parts := strings.Split(album.ReleaseDate, "-")
if len(parts) > 0 {
if y, err := strconv.ParseUint(parts[0], 10, 32); err == nil {
y32 := uint32(y)
year = &y32
}
}
}
dlResult := downloadAlbum(ctx, artist.Name, album.Title, year, indexerService, torrentService)
downloadStatus = &dlResult.status
torrentHash = dlResult.torrentHash
indexerName = dlResult.indexer
dlError = dlResult.err
switch dlResult.status {
case DownloadStatusAdded:
albumsDownloaded++
case DownloadStatusNoResults:
albumsNoResults++
case DownloadStatusFailed, DownloadStatusSkipped:
albumsFailed++
}
}
results = append(results, AlbumSyncResult{
AlbumID: album.Id,
AlbumTitle: album.Title,
Stored: stored,
DownloadStatus: downloadStatus,
TorrentHash: torrentHash,
Indexer: indexerName,
Error: dlError,
})
}
return &SyncResult{
ArtistID: artist.Id,
ArtistName: artist.Name,
TotalAlbums: len(albumsToProcess),
AlbumsStored: albumsStored,
AlbumsDownloaded: albumsDownloaded,
AlbumsNoResults: albumsNoResults,
AlbumsFailed: albumsFailed,
Results: results,
}, nil
}
func downloadAlbum(
ctx context.Context,
artistName, albumTitle string,
year *uint32,
indexerService *IndexerService,
torrentService *TorrentService,
) downloadResult {
albumStr := albumTitle
criteria := &indexer.MusicSearchCriteria{
Artist: artistName,
Album: &albumStr,
Year: year,
Limit: 20,
Offset: 0,
}
searchResults, err := indexerService.Search(ctx, criteria, nil)
if err != nil {
errStr := "indexer search failed: " + err.Error()
return downloadResult{
status: DownloadStatusFailed,
err: &errStr,
}
}
if len(searchResults) == 0 {
return downloadResult{status: DownloadStatusNoResults}
}
best := selectBestResult(searchResults)
if err := torrentService.AddTorrentURL(ctx, best.DownloadURL, nil); err != nil {
errStr := "failed to add torrent: " + err.Error()
return downloadResult{
status: DownloadStatusFailed,
indexer: &best.Indexer,
err: &errStr,
}
}
return downloadResult{
status: DownloadStatusAdded,
torrentHash: best.Infohash,
indexer: &best.Indexer,
}
}
func selectBestResult(results []indexer.SearchResult) *indexer.SearchResult {
var best *indexer.SearchResult
var bestScore int64 = -1
for i := range results {
r := &results[i]
seeders := 0
if r.Seeders != nil {
seeders = *r.Seeders
}
score := int64(seeders)
if strings.Contains(strings.ToLower(r.Title), "flac") {
score += 1000
}
if score > bestScore {
bestScore = score
best = r
}
}
return best
}
func parseUUID(s string) ([16]byte, error) {
var id [16]byte
s = strings.ReplaceAll(s, "-", "")
if len(s) != 32 {
return id, &NotFoundError{Message: "invalid uuid"}
}
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(s[i*2:i*2+2], 16, 8)
if err != nil {
return id, err
}
id[i] = byte(b)
}
return id, nil
}
type NotFoundError struct {
Message string
}
func (e *NotFoundError) Error() string {
return e.Message
}
+84
View File
@@ -0,0 +1,84 @@
package services
import (
"context"
"fmt"
"strings"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/indexer"
)
type IndexerService struct {
indexers []*indexer.TorznabIndexer
}
type IndexerInfo struct {
Name string `json:"name"`
URL string `json:"url"`
Healthy bool `json:"healthy"`
}
func NewIndexerService(configs []config.IndexerConfig) (*IndexerService, error) {
var indexers []*indexer.TorznabIndexer
for _, cfg := range configs {
url := buildTorznabURL(cfg)
idx, err := indexer.NewTorznabIndexer(cfg.Name, url, cfg.APIKey)
if err != nil {
return nil, fmt.Errorf("failed to create indexer %s: %w", cfg.Name, err)
}
indexers = append(indexers, idx)
}
return &IndexerService{indexers: indexers}, nil
}
func buildTorznabURL(cfg config.IndexerConfig) string {
url := strings.TrimRight(cfg.URL, "/")
switch cfg.IndexerType {
case config.IndexerTypeJackett:
if !strings.Contains(url, "/api/") {
url = fmt.Sprintf("%s/api/v2.0/indexers/all/results/torznab", url)
}
case config.IndexerTypeProwlarr:
if !strings.Contains(url, "/api/") {
url = fmt.Sprintf("%s/api/v1/indexer/all/newznab", url)
}
}
return url
}
func (s *IndexerService) Search(ctx context.Context, criteria *indexer.MusicSearchCriteria, indexerName *string) ([]indexer.SearchResult, error) {
var results []indexer.SearchResult
for _, idx := range s.indexers {
if indexerName != nil && idx.Name() != *indexerName {
continue
}
r, err := idx.Search(ctx, criteria)
if err != nil {
continue
}
results = append(results, r...)
}
return results, nil
}
func (s *IndexerService) GetIndexers(ctx context.Context) []IndexerInfo {
var infos []IndexerInfo
for _, idx := range s.indexers {
healthy := idx.TestConnection(ctx) == nil
infos = append(infos, IndexerInfo{
Name: idx.Name(),
Healthy: healthy,
})
}
return infos
}
+98
View File
@@ -0,0 +1,98 @@
package services
import (
"context"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/torrent"
)
type TorrentService struct {
client torrent.Client
}
func NewTorrentService(cfg config.TorrentConfig) (*TorrentService, error) {
var client torrent.Client
switch cfg.ClientType {
case config.TorrentClientQBittorrent:
c, err := torrent.NewQBittorrentClient(cfg.URL, cfg.Username, cfg.Password)
if err != nil {
return nil, err
}
client = c
case config.TorrentClientStub:
client = torrent.NewStubClient(cfg.LogPath, cfg.SavePath)
default:
return &TorrentService{client: nil}, nil
}
return &TorrentService{client: client}, nil
}
func (s *TorrentService) Connect(ctx context.Context) error {
if s.client == nil {
return nil
}
return s.client.Connect(ctx)
}
func (s *TorrentService) Disconnect(ctx context.Context) error {
if s.client == nil {
return nil
}
return s.client.Disconnect(ctx)
}
func (s *TorrentService) ListTorrents(ctx context.Context) ([]torrent.TorrentInfo, error) {
if s.client == nil {
return []torrent.TorrentInfo{}, nil
}
return s.client.ListTorrents(ctx)
}
func (s *TorrentService) GetTorrent(ctx context.Context, hash string) (*torrent.TorrentInfo, error) {
if s.client == nil {
return nil, torrent.ErrTorrentNotFound
}
return s.client.GetTorrent(ctx, hash)
}
func (s *TorrentService) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
if s.client == nil {
return nil
}
return s.client.AddTorrentURL(ctx, url, savePath)
}
func (s *TorrentService) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
if s.client == nil {
return nil
}
return s.client.AddTorrentFile(ctx, data, savePath)
}
func (s *TorrentService) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
if s.client == nil {
return nil
}
return s.client.RemoveTorrent(ctx, hash, deleteFiles)
}
func (s *TorrentService) PauseTorrent(ctx context.Context, hash string) error {
if s.client == nil {
return nil
}
return s.client.PauseTorrent(ctx, hash)
}
func (s *TorrentService) ResumeTorrent(ctx context.Context, hash string) error {
if s.client == nil {
return nil
}
return s.client.ResumeTorrent(ctx, hash)
}
func (s *TorrentService) IsConfigured() bool {
return s.client != nil
}
+49
View File
@@ -0,0 +1,49 @@
package torrent
import (
"context"
"errors"
)
var (
ErrNotConnected = errors.New("not connected")
ErrAuthFailed = errors.New("authentication failed")
ErrTorrentNotFound = errors.New("torrent not found")
ErrInvalidRequest = errors.New("invalid request")
ErrConnectionFailed = errors.New("connection failed")
)
type TorrentState string
const (
StateDownloading TorrentState = "downloading"
StateSeeding TorrentState = "seeding"
StatePaused TorrentState = "paused"
StateQueued TorrentState = "queued"
StateChecking TorrentState = "checking"
StateError TorrentState = "error"
StateUnknown TorrentState = "unknown"
)
type TorrentInfo struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size uint64 `json:"size"`
Progress float64 `json:"progress"`
DownloadSpeed uint64 `json:"download_speed"`
UploadSpeed uint64 `json:"upload_speed"`
State TorrentState `json:"state"`
SavePath string `json:"save_path"`
}
type Client interface {
Connect(ctx context.Context) error
Disconnect(ctx context.Context) error
ListTorrents(ctx context.Context) ([]TorrentInfo, error)
GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error)
AddTorrentURL(ctx context.Context, url string, savePath *string) error
AddTorrentFile(ctx context.Context, data []byte, savePath *string) error
RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error
PauseTorrent(ctx context.Context, hash string) error
ResumeTorrent(ctx context.Context, hash string) error
}
+349
View File
@@ -0,0 +1,349 @@
package torrent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
)
type QBittorrentClient struct {
baseURL string
username string
password string
client *http.Client
connected bool
mu sync.RWMutex
}
type qbTorrent struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
Progress float64 `json:"progress"`
DLSpeed int64 `json:"dlspeed"`
UPSpeed int64 `json:"upspeed"`
State string `json:"state"`
SavePath string `json:"save_path"`
}
func NewQBittorrentClient(baseURL, username, password string) (*QBittorrentClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &QBittorrentClient{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
password: password,
client: &http.Client{Jar: jar},
}, nil
}
func (c *QBittorrentClient) apiURL(path string) string {
return fmt.Sprintf("%s/api/v2%s", c.baseURL, path)
}
func (c *QBittorrentClient) mapState(state string) TorrentState {
switch state {
case "downloading", "forcedDL", "metaDL", "allocating":
return StateDownloading
case "uploading", "forcedUP", "stalledUP":
return StateSeeding
case "pausedDL", "pausedUP":
return StatePaused
case "queuedDL", "queuedUP":
return StateQueued
case "checkingDL", "checkingUP", "checkingResumeData":
return StateChecking
case "error", "missingFiles":
return StateError
default:
return StateUnknown
}
}
func (c *QBittorrentClient) mapTorrent(t qbTorrent) TorrentInfo {
size := uint64(0)
if t.Size > 0 {
size = uint64(t.Size)
}
dlSpeed := uint64(0)
if t.DLSpeed > 0 {
dlSpeed = uint64(t.DLSpeed)
}
upSpeed := uint64(0)
if t.UPSpeed > 0 {
upSpeed = uint64(t.UPSpeed)
}
return TorrentInfo{
Hash: t.Hash,
Name: t.Name,
Size: size,
Progress: t.Progress,
DownloadSpeed: dlSpeed,
UploadSpeed: upSpeed,
State: c.mapState(t.State),
SavePath: t.SavePath,
}
}
func (c *QBittorrentClient) ensureConnected() error {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.connected {
return ErrNotConnected
}
return nil
}
func (c *QBittorrentClient) Connect(ctx context.Context) error {
data := url.Values{}
data.Set("username", c.username)
data.Set("password", c.password)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/login"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrConnectionFailed, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) == "Ok." {
c.mu.Lock()
c.connected = true
c.mu.Unlock()
return nil
}
return ErrAuthFailed
}
func (c *QBittorrentClient) Disconnect(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/logout"), nil)
if err != nil {
return err
}
c.client.Do(req)
c.mu.Lock()
c.connected = false
c.mu.Unlock()
return nil
}
func (c *QBittorrentClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) {
if err := c.ensureConnected(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info"), nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var torrents []qbTorrent
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, err
}
result := make([]TorrentInfo, len(torrents))
for i, t := range torrents {
result[i] = c.mapTorrent(t)
}
return result, nil
}
func (c *QBittorrentClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
if err := c.ensureConnected(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info")+"?hashes="+hash, nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var torrents []qbTorrent
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, err
}
if len(torrents) == 0 {
return nil, ErrTorrentNotFound
}
info := c.mapTorrent(torrents[0])
return &info, nil
}
func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string, savePath *string) error {
if err := c.ensureConnected(); err != nil {
return err
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
w.WriteField("urls", torrentURL)
if savePath != nil {
w.WriteField("savepath", *savePath)
}
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body))
}
return nil
}
func (c *QBittorrentClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
if err := c.ensureConnected(); err != nil {
return err
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("torrents", "torrent.torrent")
if err != nil {
return err
}
part.Write(data)
if savePath != nil {
w.WriteField("savepath", *savePath)
}
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body))
}
return nil
}
func (c *QBittorrentClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
if deleteFiles {
data.Set("deleteFiles", "true")
} else {
data.Set("deleteFiles", "false")
}
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/delete"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
return ErrTorrentNotFound
}
return nil
}
func (c *QBittorrentClient) PauseTorrent(ctx context.Context, hash string) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/pause"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.client.Do(req)
return nil
}
func (c *QBittorrentClient) ResumeTorrent(ctx context.Context, hash string) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/resume"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.client.Do(req)
return nil
}
func statusOK(code int) bool {
return code >= 200 && code < 300
}
+90
View File
@@ -0,0 +1,90 @@
package torrent
import (
"context"
"fmt"
"os"
"sync"
"time"
)
type StubClient struct {
logPath string
savePath string
mu sync.Mutex
}
func NewStubClient(logPath, savePath string) *StubClient {
return &StubClient{
logPath: logPath,
savePath: savePath,
}
}
func (c *StubClient) log(format string, args ...any) {
c.mu.Lock()
defer c.mu.Unlock()
f, err := os.OpenFile(c.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
timestamp := time.Now().Format(time.RFC3339)
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(f, "[%s] %s\n", timestamp, msg)
}
func (c *StubClient) Connect(ctx context.Context) error {
c.log("CONNECT")
return nil
}
func (c *StubClient) Disconnect(ctx context.Context) error {
c.log("DISCONNECT")
return nil
}
func (c *StubClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) {
c.log("LIST_TORRENTS")
return []TorrentInfo{}, nil
}
func (c *StubClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
c.log("GET_TORRENT hash=%s", hash)
return nil, ErrTorrentNotFound
}
func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
c.log("ADD_TORRENT_URL url=%s save_path=%s", url, path)
return nil
}
func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
c.log("ADD_TORRENT_FILE size=%d save_path=%s", len(data), path)
return nil
}
func (c *StubClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
c.log("REMOVE_TORRENT hash=%s delete_files=%t", hash, deleteFiles)
return nil
}
func (c *StubClient) PauseTorrent(ctx context.Context, hash string) error {
c.log("PAUSE_TORRENT hash=%s", hash)
return nil
}
func (c *StubClient) ResumeTorrent(ctx context.Context, hash string) error {
c.log("RESUME_TORRENT hash=%s", hash)
return nil
}
+1 -1
View File
@@ -2,7 +2,7 @@ syntax = "proto3";
package metadata.v1; package metadata.v1;
option go_package = "github.com/metadata-agregator/pkg/gen/metadata/v1;metadatav1"; option go_package = "github.com/fujin/music-agregator/pkg/metadatapb/metadata/v1;metadatav1";
enum Provider { enum Provider {
PROVIDER_UNSPECIFIED = 0; PROVIDER_UNSPECIFIED = 0;
-92
View File
@@ -1,92 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::indexer::{MusicSearchCriteria, SearchResult};
use crate::services::IndexerInfo;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list_indexers))
.route("/search", post(search))
.route("/{name}/test", get(test_indexer))
}
async fn list_indexers(State(state): State<AppState>) -> Json<Vec<IndexerInfo>> {
let state = state.read().await;
Json(state.indexer_service.list_indexers())
}
#[derive(Debug, Deserialize)]
pub struct SearchRequest {
pub artist: String,
pub album: Option<String>,
pub year: Option<u32>,
pub limit: Option<u32>,
pub indexer: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
pub total: usize,
}
async fn search(
State(state): State<AppState>,
Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, (StatusCode, String)> {
let mut criteria = MusicSearchCriteria::new(&req.artist);
if let Some(album) = &req.album {
criteria = criteria.with_album(album);
}
if let Some(year) = req.year {
criteria = criteria.with_year(year);
}
if let Some(limit) = req.limit {
criteria = criteria.with_limit(limit);
}
let state = state.read().await;
let results = state
.indexer_service
.search(&criteria, req.indexer.as_deref())
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
let total = results.len();
Ok(Json(SearchResponse { results, total }))
}
#[derive(Debug, Serialize)]
pub struct TestResponse {
pub success: bool,
pub message: String,
}
async fn test_indexer(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<TestResponse>, (StatusCode, Json<TestResponse>)> {
let state = state.read().await;
match state.indexer_service.test_indexer(&name).await {
Ok(()) => Ok(Json(TestResponse {
success: true,
message: "Connection successful".to_string(),
})),
Err(e) => Err((
StatusCode::BAD_GATEWAY,
Json(TestResponse {
success: false,
message: e.to_string(),
}),
)),
}
}
-124
View File
@@ -1,124 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::Serialize;
use uuid::Uuid;
use crate::services::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow};
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/artists", get(list_artists))
.route("/artists/{id}/albums", get(list_artist_albums))
.route("/albums", get(list_albums))
.route("/stats", get(library_stats))
}
#[derive(Serialize)]
struct ArtistsResponse {
artists: Vec<ArtistMetadataRow>,
total: usize,
}
async fn list_artists(
State(state): State<AppState>,
) -> Result<Json<ArtistsResponse>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let artists = db
.list_artists()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = artists.len();
Ok(Json(ArtistsResponse { artists, total }))
}
#[derive(Serialize)]
struct ArtistAlbumsResponse {
albums: Vec<AlbumRow>,
total: usize,
}
async fn list_artist_albums(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<ArtistAlbumsResponse>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let albums = db
.list_albums_by_artist(id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = albums.len();
Ok(Json(ArtistAlbumsResponse { albums, total }))
}
#[derive(Serialize)]
struct AlbumsResponse {
albums: Vec<AlbumWithArtistRow>,
total: usize,
}
async fn list_albums(
State(state): State<AppState>,
) -> Result<Json<AlbumsResponse>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let albums = db
.list_all_albums()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = albums.len();
Ok(Json(AlbumsResponse { albums, total }))
}
#[derive(Serialize)]
struct LibraryStats {
artists: i64,
albums: i64,
}
async fn library_stats(
State(state): State<AppState>,
) -> Result<Json<LibraryStats>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let artists = db
.count_artists()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let albums = db
.count_albums()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(LibraryStats { artists, albums }))
}
-342
View File
@@ -1,342 +0,0 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::metadata::proto::{Album, Artist, ArtistCredit, ExternalId, Genre, Label, Track, Work};
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/artists/search", get(search_artists))
.route("/artists/{id}", get(get_artist))
.route("/artists/{id}/albums", get(get_artist_albums))
.route("/artists/sync", post(sync_artist))
.route("/albums/{id}", get(get_album))
.route("/albums/{id}/tracks", get(get_album_tracks))
.route("/status", get(connection_status))
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: String,
pub limit: Option<i32>,
pub offset: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct ArtistResponse {
pub id: String,
pub name: String,
pub sort_name: String,
pub artist_type: String,
pub country: String,
pub formed_date: String,
pub disbanded_date: String,
pub description: String,
pub image_url: String,
pub genres: Vec<GenreResponse>,
pub external_ids: Vec<ExternalIdResponse>,
}
#[derive(Debug, Serialize)]
pub struct GenreResponse {
pub id: String,
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct ExternalIdResponse {
pub source: String,
pub source_id: String,
pub url: String,
}
#[derive(Debug, Serialize)]
pub struct AlbumResponse {
pub id: String,
pub title: String,
pub album_type: String,
pub release_date: String,
pub upc: String,
pub total_tracks: i32,
pub total_discs: i32,
pub cover_url: String,
pub artists: Vec<ArtistCreditResponse>,
pub label: Option<LabelResponse>,
pub genres: Vec<GenreResponse>,
pub external_ids: Vec<ExternalIdResponse>,
}
#[derive(Debug, Serialize)]
pub struct ArtistCreditResponse {
pub artist: Option<ArtistResponse>,
pub role: String,
pub position: i32,
pub join_phrase: String,
}
#[derive(Debug, Serialize)]
pub struct LabelResponse {
pub id: String,
pub name: String,
pub country: String,
}
#[derive(Debug, Serialize)]
pub struct TrackResponse {
pub id: String,
pub title: String,
pub duration_ms: i32,
pub isrc: String,
pub explicit: bool,
pub disc_number: i32,
pub track_number: i32,
pub artists: Vec<ArtistCreditResponse>,
pub work: Option<WorkResponse>,
pub external_ids: Vec<ExternalIdResponse>,
}
#[derive(Debug, Serialize)]
pub struct WorkResponse {
pub id: String,
pub title: String,
pub work_type: String,
pub language: String,
}
#[derive(Debug, Serialize)]
pub struct SearchArtistsResponse {
pub artists: Vec<ArtistResponse>,
pub total: i32,
}
#[derive(Debug, Serialize)]
pub struct ArtistAlbumsResponse {
pub albums: Vec<AlbumResponse>,
pub total: i32,
}
#[derive(Debug, Serialize)]
pub struct AlbumTracksResponse {
pub tracks: Vec<TrackResponse>,
}
#[derive(Debug, Deserialize)]
pub struct SyncRequest {
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct SyncResponse {
pub artist: Option<ArtistResponse>,
pub albums_synced: i32,
pub tracks_synced: i32,
}
fn map_genre(g: &Genre) -> GenreResponse {
GenreResponse {
id: g.id.clone(),
name: g.name.clone(),
}
}
fn map_external_id(e: &ExternalId) -> ExternalIdResponse {
ExternalIdResponse {
source: e.source.clone(),
source_id: e.source_id.clone(),
url: e.url.clone(),
}
}
fn map_artist(a: &Artist) -> ArtistResponse {
ArtistResponse {
id: a.id.clone(),
name: a.name.clone(),
sort_name: a.sort_name.clone(),
artist_type: a.artist_type.clone(),
country: a.country.clone(),
formed_date: a.formed_date.clone(),
disbanded_date: a.disbanded_date.clone(),
description: a.description.clone(),
image_url: a.image_url.clone(),
genres: a.genres.iter().map(map_genre).collect(),
external_ids: a.external_ids.iter().map(map_external_id).collect(),
}
}
fn map_label(l: &Label) -> LabelResponse {
LabelResponse {
id: l.id.clone(),
name: l.name.clone(),
country: l.country.clone(),
}
}
fn map_artist_credit(c: &ArtistCredit) -> ArtistCreditResponse {
ArtistCreditResponse {
artist: c.artist.as_ref().map(map_artist),
role: c.role.clone(),
position: c.position,
join_phrase: c.join_phrase.clone(),
}
}
fn map_album(a: &Album) -> AlbumResponse {
AlbumResponse {
id: a.id.clone(),
title: a.title.clone(),
album_type: a.album_type.clone(),
release_date: a.release_date.clone(),
upc: a.upc.clone(),
total_tracks: a.total_tracks,
total_discs: a.total_discs,
cover_url: a.cover_url.clone(),
artists: a.artists.iter().map(map_artist_credit).collect(),
label: a.label.as_ref().map(map_label),
genres: a.genres.iter().map(map_genre).collect(),
external_ids: a.external_ids.iter().map(map_external_id).collect(),
}
}
fn map_work(w: &Work) -> WorkResponse {
WorkResponse {
id: w.id.clone(),
title: w.title.clone(),
work_type: w.work_type.clone(),
language: w.language.clone(),
}
}
fn map_track(t: &Track) -> TrackResponse {
TrackResponse {
id: t.id.clone(),
title: t.title.clone(),
duration_ms: t.duration_ms,
isrc: t.isrc.clone(),
explicit: t.explicit,
disc_number: t.disc_number,
track_number: t.track_number,
artists: t.artists.iter().map(map_artist_credit).collect(),
work: t.work.as_ref().map(map_work),
external_ids: t.external_ids.iter().map(map_external_id).collect(),
}
}
async fn search_artists(
State(state): State<AppState>,
Query(query): Query<SearchQuery>,
) -> Result<Json<SearchArtistsResponse>, (StatusCode, String)> {
let state = state.read().await;
let response = state
.metadata_service
.search_artists(&query.q, query.limit, query.offset)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Json(SearchArtistsResponse {
artists: response.artists.iter().map(map_artist).collect(),
total: response.total,
}))
}
async fn get_artist(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<ArtistResponse>, (StatusCode, String)> {
let state = state.read().await;
let artist = state
.metadata_service
.get_artist(&id)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Json(map_artist(&artist)))
}
async fn get_artist_albums(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<PaginationQuery>,
) -> Result<Json<ArtistAlbumsResponse>, (StatusCode, String)> {
let state = state.read().await;
let response = state
.metadata_service
.get_artist_albums(&id, query.limit, query.offset)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Json(ArtistAlbumsResponse {
albums: response.albums.iter().map(map_album).collect(),
total: response.total,
}))
}
#[derive(Debug, Deserialize)]
pub struct PaginationQuery {
pub limit: Option<i32>,
pub offset: Option<i32>,
}
async fn get_album(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<AlbumResponse>, (StatusCode, String)> {
let state = state.read().await;
let album = state
.metadata_service
.get_album(&id)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Json(map_album(&album)))
}
async fn get_album_tracks(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<AlbumTracksResponse>, (StatusCode, String)> {
let state = state.read().await;
let response = state
.metadata_service
.get_album_tracks(&id)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Json(AlbumTracksResponse {
tracks: response.tracks.iter().map(map_track).collect(),
}))
}
async fn sync_artist(
State(state): State<AppState>,
Json(req): Json<SyncRequest>,
) -> Result<Json<SyncResponse>, (StatusCode, String)> {
let state = state.read().await;
let response = state
.metadata_service
.sync_artist(&req.name)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Json(SyncResponse {
artist: response.artist.as_ref().map(map_artist),
albums_synced: response.albums_synced,
tracks_synced: response.tracks_synced,
}))
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub connected: bool,
}
async fn connection_status(State(state): State<AppState>) -> Json<StatusResponse> {
let state = state.read().await;
Json(StatusResponse {
connected: state.metadata_service.is_connected(),
})
}
-157
View File
@@ -1,157 +0,0 @@
mod indexer_controller;
mod library_controller;
mod metadata_controller;
mod sync_controller;
mod torrent_controller;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{delete, get, post},
Json, Router,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::models::{CreateTrack, Track};
use crate::AppState;
pub fn routes(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/reload", post(reload))
.route("/tracks", get(list_tracks))
.route("/tracks", post(create_track))
.route("/tracks/{id}", get(get_track))
.route("/tracks/{id}", delete(delete_track))
.route("/tracks/search", get(search_tracks))
.route("/stats", get(get_stats))
.nest("/indexers", indexer_controller::routes())
.nest("/torrents", torrent_controller::routes())
.nest("/metadata", metadata_controller::routes())
.nest("/sync", sync_controller::routes())
.nest("/library", library_controller::routes())
.with_state(state)
}
#[derive(serde::Serialize)]
struct Health {
status: &'static str,
services: ServiceStatus,
}
#[derive(serde::Serialize)]
struct ServiceStatus {
torrent: bool,
metadata: bool,
indexers: Vec<String>,
}
async fn health(State(state): State<AppState>) -> Json<Health> {
let state = state.read().await;
let indexers = state
.indexer_service
.list_indexers()
.into_iter()
.map(|i| i.name)
.collect();
Json(Health {
status: "ok",
services: ServiceStatus {
torrent: state.torrent_service.is_connected().await,
metadata: state.metadata_service.is_connected(),
indexers,
},
})
}
#[derive(serde::Serialize)]
struct ReloadResponse {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
async fn reload(State(state): State<AppState>) -> Json<ReloadResponse> {
let mut state = state.write().await;
match state.reload().await {
Ok(()) => Json(ReloadResponse {
success: true,
error: None,
}),
Err(e) => Json(ReloadResponse {
success: false,
error: Some(e),
}),
}
}
async fn list_tracks(State(state): State<AppState>) -> Json<Vec<Track>> {
let state = state.read().await;
Json(state.aggregator.get_all().to_vec())
}
async fn create_track(
State(state): State<AppState>,
Json(input): Json<CreateTrack>,
) -> (StatusCode, Json<Track>) {
let mut state = state.write().await;
let track = state.aggregator.add_track(input.into());
(StatusCode::CREATED, Json(track))
}
async fn get_track(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Track>, StatusCode> {
let state = state.read().await;
state
.aggregator
.get_by_id(id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn delete_track(State(state): State<AppState>, Path(id): Path<Uuid>) -> StatusCode {
let mut state = state.write().await;
if state.aggregator.delete(id) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[derive(Deserialize)]
struct SearchQuery {
artist: String,
}
async fn search_tracks(
State(state): State<AppState>,
Query(query): Query<SearchQuery>,
) -> Json<Vec<Track>> {
let state = state.read().await;
Json(
state
.aggregator
.search_by_artist(&query.artist)
.into_iter()
.cloned()
.collect(),
)
}
#[derive(serde::Serialize)]
struct Stats {
track_count: usize,
total_duration_secs: u32,
}
async fn get_stats(State(state): State<AppState>) -> Json<Stats> {
let state = state.read().await;
Json(Stats {
track_count: state.aggregator.get_all().len(),
total_duration_secs: state.aggregator.total_duration(),
})
}
-49
View File
@@ -1,49 +0,0 @@
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::Deserialize;
use crate::services::{DownloadService, SyncOptions, SyncResult};
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new().route("/", post(sync))
}
#[derive(Debug, Deserialize)]
pub struct SyncRequest {
pub artist: String,
pub album: Option<String>,
#[serde(default = "default_true")]
pub download: bool,
#[serde(default = "default_true")]
pub store: bool,
}
fn default_true() -> bool {
true
}
async fn sync(
State(state): State<AppState>,
Json(req): Json<SyncRequest>,
) -> Result<Json<SyncResult>, (StatusCode, String)> {
let state = state.read().await;
let options = SyncOptions {
artist: req.artist,
album: req.album,
download: req.download,
store: req.store,
};
let result = DownloadService::sync(
options,
&state.metadata_service,
&state.indexer_service,
&state.torrent_service,
state.db_service.as_ref(),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(result))
}
-210
View File
@@ -1,210 +0,0 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{delete, get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::torrent::TorrentInfo;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list_torrents))
.route("/{hash}", get(get_torrent))
.route("/{hash}", delete(remove_torrent))
.route("/{hash}/pause", post(pause_torrent))
.route("/{hash}/resume", post(resume_torrent))
.route("/add/url", post(add_torrent_url))
.route("/add/file", post(add_torrent_file))
.route("/status", get(connection_status))
}
#[derive(Debug, Serialize)]
pub struct TorrentListResponse {
pub torrents: Vec<TorrentInfo>,
pub total: usize,
}
async fn list_torrents(
State(state): State<AppState>,
) -> Result<Json<TorrentListResponse>, (StatusCode, String)> {
let state = state.read().await;
let torrents = state
.torrent_service
.list_torrents()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
let total = torrents.len();
Ok(Json(TorrentListResponse { torrents, total }))
}
async fn get_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<Json<TorrentInfo>, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.get_torrent(&hash)
.await
.map(Json)
.map_err(|e| {
let status = if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_GATEWAY
};
(status, e.to_string())
})
}
#[derive(Debug, Deserialize)]
pub struct RemoveQuery {
#[serde(default)]
pub delete_files: bool,
}
async fn remove_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
Query(query): Query<RemoveQuery>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.remove_torrent(&hash, query.delete_files)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
async fn pause_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.pause_torrent(&hash)
.await
.map(|_| StatusCode::OK)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
async fn resume_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.resume_torrent(&hash)
.await
.map(|_| StatusCode::OK)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
#[derive(Debug, Deserialize)]
pub struct AddUrlRequest {
pub url: String,
pub save_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AddResponse {
pub success: bool,
pub message: String,
}
async fn add_torrent_url(
State(state): State<AppState>,
Json(req): Json<AddUrlRequest>,
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
let state = state.read().await;
state
.torrent_service
.add_torrent_url(&req.url, req.save_path.as_deref())
.await
.map(|_| {
(
StatusCode::CREATED,
Json(AddResponse {
success: true,
message: "Torrent added successfully".to_string(),
}),
)
})
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: e.to_string(),
}),
)
})
}
#[derive(Debug, Deserialize)]
pub struct AddFileRequest {
pub torrent_base64: String,
pub save_path: Option<String>,
}
async fn add_torrent_file(
State(state): State<AppState>,
Json(req): Json<AddFileRequest>,
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD
.decode(&req.torrent_base64)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: format!("Invalid base64: {}", e),
}),
)
})?;
let state = state.read().await;
state
.torrent_service
.add_torrent_file(&data, req.save_path.as_deref())
.await
.map(|_| {
(
StatusCode::CREATED,
Json(AddResponse {
success: true,
message: "Torrent added successfully".to_string(),
}),
)
})
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: e.to_string(),
}),
)
})
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub connected: bool,
}
async fn connection_status(State(state): State<AppState>) -> Json<StatusResponse> {
let state = state.read().await;
Json(StatusResponse {
connected: state.torrent_service.is_connected().await,
})
}
-103
View File
@@ -1,103 +0,0 @@
use serde::Deserialize;
use std::fs;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
ReadError(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
ParseError(#[from] serde_yaml::Error),
}
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub app: AppConfig,
pub database: DatabaseConfig,
pub metadata: MetadataConfig,
pub indexers: Vec<IndexerConfig>,
pub torrent: TorrentConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
#[serde(default = "default_port")]
pub port: u16,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
port: default_port(),
}
}
}
fn default_port() -> u16 {
3000
}
#[derive(Debug, Clone, Deserialize)]
pub struct MetadataConfig {
pub endpoint: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IndexerType {
#[default]
Jackett,
Prowlarr,
Torznab,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IndexerConfig {
pub name: String,
#[serde(default)]
pub indexer_type: IndexerType,
pub url: String,
pub api_key: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "client_type", rename_all = "lowercase")]
pub enum TorrentConfig {
QBittorrent {
url: String,
username: String,
password: String,
},
Stub {
log_path: String,
#[serde(default = "default_stub_save_path")]
save_path: String,
},
None,
}
impl Default for TorrentConfig {
fn default() -> Self {
Self::None
}
}
fn default_stub_save_path() -> String {
"/tmp/downloads".to_string()
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path)?;
let config: Config = serde_yaml::from_str(&content)?;
Ok(config)
}
}
-43
View File
@@ -1,43 +0,0 @@
mod search;
mod torznab;
pub use search::{MusicSearchCriteria, SearchResult};
pub use torznab::TorznabIndexer;
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IndexerError {
#[error("authentication failed: invalid API key")]
AuthenticationFailed,
#[error("rate limited: retry after {0} seconds")]
RateLimited(u64),
#[error("indexer unavailable: {0}")]
Unavailable(String),
#[error("search failed: {0}")]
SearchFailed(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
}
#[async_trait]
pub trait Indexer: Send + Sync {
fn name(&self) -> &str;
fn supports_music_search(&self) -> bool;
async fn search(
&self,
criteria: &MusicSearchCriteria,
) -> Result<Vec<SearchResult>, IndexerError>;
async fn test_connection(&self) -> Result<(), IndexerError>;
}
-79
View File
@@ -1,79 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct MusicSearchCriteria {
pub artist: String,
pub album: Option<String>,
pub year: Option<u32>,
pub limit: u32,
pub offset: u32,
}
impl MusicSearchCriteria {
pub fn new(artist: impl Into<String>) -> Self {
Self {
artist: artist.into(),
album: None,
year: None,
limit: 100,
offset: 0,
}
}
pub fn with_album(mut self, album: impl Into<String>) -> Self {
self.album = Some(album.into());
self
}
pub fn with_year(mut self, year: u32) -> Self {
self.year = Some(year);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = limit;
self
}
pub fn with_offset(mut self, offset: u32) -> Self {
self.offset = offset;
self
}
pub fn clean_artist(&self) -> String {
normalize_query(&self.artist)
}
pub fn clean_album(&self) -> Option<String> {
self.album.as_ref().map(|a| normalize_query(a))
}
}
fn normalize_query(s: &str) -> String {
s.trim().replace("\"", "").replace("'", "").to_lowercase()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub guid: String,
pub title: String,
pub download_url: String,
pub info_url: Option<String>,
pub size: u64,
pub publish_date: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<u32>,
pub label: Option<String>,
pub seeders: Option<u32>,
pub leechers: Option<u32>,
pub grabs: Option<u32>,
pub infohash: Option<String>,
pub magnet_url: Option<String>,
pub indexer: String,
pub categories: Vec<u32>,
}
-221
View File
@@ -1,221 +0,0 @@
use async_trait::async_trait;
use reqwest::Client;
use url::Url;
use super::search::{MusicSearchCriteria, SearchResult};
use super::{Indexer, IndexerError};
pub struct TorznabIndexer {
name: String,
base_url: Url,
api_key: String,
categories: Vec<u32>,
http: Client,
}
impl TorznabIndexer {
pub fn new(
name: impl Into<String>,
base_url: &str,
api_key: impl Into<String>,
) -> Result<Self, IndexerError> {
let base_url = Url::parse(base_url)
.map_err(|e| IndexerError::SearchFailed(format!("invalid URL: {}", e)))?;
Ok(Self {
name: name.into(),
base_url,
api_key: api_key.into(),
categories: vec![3000, 3010, 3040],
http: Client::new(),
})
}
pub fn with_categories(mut self, categories: Vec<u32>) -> Self {
self.categories = categories;
self
}
fn build_search_url(&self, criteria: &MusicSearchCriteria) -> Result<Url, IndexerError> {
let mut url = self.base_url.clone();
{
let mut query = url.query_pairs_mut();
query.append_pair("t", "music");
query.append_pair("apikey", &self.api_key);
query.append_pair("extended", "1");
let cats = self
.categories
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(",");
query.append_pair("cat", &cats);
let mut q_parts = vec![criteria.clean_artist()];
if let Some(album) = criteria.clean_album() {
q_parts.push(album);
}
if let Some(year) = criteria.year {
q_parts.push(year.to_string());
}
query.append_pair("q", &q_parts.join(" "));
query.append_pair("limit", &criteria.limit.to_string());
query.append_pair("offset", &criteria.offset.to_string());
}
Ok(url)
}
fn parse_response(&self, xml: &str) -> Result<Vec<SearchResult>, IndexerError> {
let mut results = Vec::new();
let doc = roxmltree::Document::parse(xml)
.map_err(|e| IndexerError::ParseError(format!("XML parse error: {}", e)))?;
if let Some(error) = doc.descendants().find(|n| n.has_tag_name("error")) {
let code = error.attribute("code").unwrap_or("0");
let desc = error.attribute("description").unwrap_or("Unknown error");
if code.starts_with("1") {
return Err(IndexerError::AuthenticationFailed);
}
return Err(IndexerError::SearchFailed(desc.to_string()));
}
for item in doc.descendants().filter(|n| n.has_tag_name("item")) {
let result = self.parse_item(&item)?;
results.push(result);
}
Ok(results)
}
fn parse_item(&self, item: &roxmltree::Node) -> Result<SearchResult, IndexerError> {
let get_text = |tag: &str| -> Option<String> {
item.children()
.find(|n| n.has_tag_name(tag))
.and_then(|n| n.text())
.map(|s| s.to_string())
};
let get_attr = |name: &str| -> Option<String> {
item.children()
.filter(|n| n.has_tag_name("attr"))
.find(|n| n.attribute("name") == Some(name))
.and_then(|n| n.attribute("value"))
.map(|s| s.to_string())
};
let guid = get_text("guid").unwrap_or_default();
let title = get_text("title").unwrap_or_default();
let download_url = get_text("link").unwrap_or_default();
let size = get_attr("size")
.or_else(|| {
item.children()
.find(|n| n.has_tag_name("enclosure"))
.and_then(|n| n.attribute("length"))
.map(|s| s.to_string())
})
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let mut categories = Vec::new();
for attr in item.children().filter(|n| n.has_tag_name("attr")) {
if attr.attribute("name") == Some("category") {
if let Some(val) = attr.attribute("value") {
if let Ok(cat) = val.parse::<u32>() {
categories.push(cat);
}
}
}
}
Ok(SearchResult {
guid,
title,
download_url,
info_url: get_text("comments"),
size,
publish_date: get_text("pubDate"),
artist: get_attr("artist"),
album: get_attr("album"),
year: get_attr("year").and_then(|s| s.parse().ok()),
label: get_attr("label"),
seeders: get_attr("seeders").and_then(|s| s.parse().ok()),
leechers: get_attr("leechers").and_then(|s| s.parse().ok()),
grabs: get_attr("grabs").and_then(|s| s.parse().ok()),
infohash: get_attr("infohash"),
magnet_url: get_attr("magneturl"),
indexer: self.name.clone(),
categories,
})
}
}
#[async_trait]
impl Indexer for TorznabIndexer {
fn name(&self) -> &str {
&self.name
}
fn supports_music_search(&self) -> bool {
true
}
async fn search(
&self,
criteria: &MusicSearchCriteria,
) -> Result<Vec<SearchResult>, IndexerError> {
let url = self.build_search_url(criteria)?;
let response = self.http.get(url).send().await?;
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
return Err(IndexerError::RateLimited(retry_after));
}
if !response.status().is_success() {
return Err(IndexerError::Unavailable(format!(
"HTTP {}",
response.status()
)));
}
let xml = response.text().await?;
self.parse_response(&xml)
}
async fn test_connection(&self) -> Result<(), IndexerError> {
let mut url = self.base_url.clone();
url.query_pairs_mut()
.append_pair("t", "caps")
.append_pair("apikey", &self.api_key);
let response = self.http.get(url).send().await?;
if !response.status().is_success() {
return Err(IndexerError::Unavailable(format!(
"HTTP {}",
response.status()
)));
}
let xml = response.text().await?;
if xml.contains("<error") && xml.contains("code=\"1") {
return Err(IndexerError::AuthenticationFailed);
}
Ok(())
}
}
-64
View File
@@ -1,64 +0,0 @@
pub mod api;
pub mod config;
pub mod indexer;
pub mod metadata;
pub mod models;
pub mod services;
pub mod torrent;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct AppServices {
pub aggregator: services::Aggregator,
pub indexer_service: services::IndexerService,
pub torrent_service: services::TorrentService,
pub metadata_service: services::MetadataService,
pub db_service: Option<services::DbService>,
config_path: String,
}
impl AppServices {
pub fn new(
indexer_service: services::IndexerService,
torrent_service: services::TorrentService,
metadata_service: services::MetadataService,
db_service: Option<services::DbService>,
config_path: String,
) -> Self {
Self {
aggregator: services::Aggregator::new(),
indexer_service,
torrent_service,
metadata_service,
db_service,
config_path,
}
}
pub async fn reload(&mut self) -> Result<(), String> {
let cfg = config::Config::load(&self.config_path).map_err(|e| e.to_string())?;
self.indexer_service =
services::IndexerService::from_config(&cfg.indexers).map_err(|e| e.to_string())?;
match services::TorrentService::from_config(&cfg.torrent).await {
Ok(svc) => self.torrent_service = svc,
Err(e) => {
tracing::warn!("failed to init torrent client on reload: {}", e);
}
}
let mut metadata = services::MetadataService::new(&cfg.metadata.endpoint);
if metadata.connect().await.is_ok() {
self.metadata_service = metadata;
} else {
tracing::warn!("failed to connect to metadata service on reload");
}
tracing::info!("config reloaded from {}", self.config_path);
Ok(())
}
}
pub type AppState = Arc<RwLock<AppServices>>;
-131
View File
@@ -1,131 +0,0 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use axum::Router;
use clap::Parser;
use music_agregator::{
api, config,
services::{DbService, IndexerService, MetadataService, TorrentService},
AppServices, AppState,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser)]
#[command(name = "music-agregator")]
#[command(about = "Music aggregation service with torrent and metadata integration")]
struct Args {
#[arg(short, long, default_value = "config.yaml")]
config: String,
#[arg(short, long)]
port: Option<u16>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(tracing_subscriber::EnvFilter::from_default_env())
.init();
let args = Args::parse();
let config = match config::Config::load(&args.config) {
Ok(cfg) => {
tracing::info!("loaded config from {}", args.config);
cfg
}
Err(e) => {
tracing::error!("failed to load config: {}", e);
std::process::exit(1);
}
};
let indexer_service = match IndexerService::from_config(&config.indexers) {
Ok(svc) => {
tracing::info!("initialized {} indexer(s)", config.indexers.len());
svc
}
Err(e) => {
tracing::error!("failed to initialize indexer service: {}", e);
std::process::exit(1);
}
};
let torrent_service = match TorrentService::from_config(&config.torrent).await {
Ok(svc) => {
match &config.torrent {
config::TorrentConfig::QBittorrent { url, .. } => {
tracing::info!("connected to qBittorrent at {}", url);
}
config::TorrentConfig::Stub { log_path, .. } => {
tracing::info!("using stub torrent client, logging to {}", log_path);
}
config::TorrentConfig::None => {
tracing::info!("no torrent client configured");
}
}
svc
}
Err(e) => {
tracing::warn!("failed to init torrent client: {} (continuing without)", e);
TorrentService::new()
}
};
let mut metadata_service = MetadataService::new(&config.metadata.endpoint);
match metadata_service.connect().await {
Ok(()) => {
tracing::info!(
"connected to metadata service at {}",
config.metadata.endpoint
);
}
Err(e) => {
tracing::warn!(
"failed to connect to metadata service: {} (continuing without metadata)",
e
);
}
}
let db_service = match DbService::new(&config.database.url).await {
Ok(svc) => {
tracing::info!("connected to database");
Some(svc)
}
Err(e) => {
tracing::warn!(
"failed to connect to database: {} (continuing without db)",
e
);
None
}
};
let state: AppState = Arc::new(RwLock::new(AppServices::new(
indexer_service,
torrent_service,
metadata_service,
db_service,
args.config.clone(),
)));
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.nest("/api", api::routes(state))
.layer(cors)
.layer(TraceLayer::new_for_http());
let port = args.port.unwrap_or(config.app.port);
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
-112
View File
@@ -1,112 +0,0 @@
use thiserror::Error;
use tonic::transport::Channel;
use super::proto::{
get_album_request, get_artist_request, metadata_service_client::MetadataServiceClient,
sync_artist_request, Album, Artist, GetAlbumRequest, GetAlbumTracksRequest,
GetAlbumTracksResponse, GetArtistAlbumsRequest, GetArtistAlbumsResponse, GetArtistRequest,
Provider, SearchArtistsRequest, SearchArtistsResponse, SyncArtistRequest, SyncArtistResponse,
};
#[derive(Debug, Error)]
pub enum MetadataClientError {
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("request failed: {0}")]
RequestFailed(#[from] tonic::Status),
#[error("transport error: {0}")]
Transport(#[from] tonic::transport::Error),
}
pub struct MetadataClient {
client: MetadataServiceClient<Channel>,
}
impl MetadataClient {
pub async fn connect(endpoint: &str) -> Result<Self, MetadataClientError> {
let client = MetadataServiceClient::connect(endpoint.to_string()).await?;
Ok(Self { client })
}
pub async fn search_artists(
&mut self,
query: &str,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<SearchArtistsResponse, MetadataClientError> {
let request = SearchArtistsRequest {
query: query.to_string(),
limit: limit.unwrap_or(20),
offset: offset.unwrap_or(0),
provider: Provider::Unspecified as i32,
};
let response = self.client.search_artists(request).await?;
Ok(response.into_inner())
}
pub async fn get_artist(&mut self, id: &str) -> Result<Artist, MetadataClientError> {
let request = GetArtistRequest {
identifier: Some(get_artist_request::Identifier::Id(id.to_string())),
provider: Provider::Unspecified as i32,
};
let response = self.client.get_artist(request).await?;
Ok(response.into_inner())
}
pub async fn get_artist_albums(
&mut self,
artist_id: &str,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<GetArtistAlbumsResponse, MetadataClientError> {
let request = GetArtistAlbumsRequest {
artist_id: artist_id.to_string(),
limit: limit.unwrap_or(50),
offset: offset.unwrap_or(0),
provider: Provider::Unspecified as i32,
};
let response = self.client.get_artist_albums(request).await?;
Ok(response.into_inner())
}
pub async fn get_album(&mut self, id: &str) -> Result<Album, MetadataClientError> {
let request = GetAlbumRequest {
identifier: Some(get_album_request::Identifier::Id(id.to_string())),
provider: Provider::Unspecified as i32,
};
let response = self.client.get_album(request).await?;
Ok(response.into_inner())
}
pub async fn get_album_tracks(
&mut self,
album_id: &str,
) -> Result<GetAlbumTracksResponse, MetadataClientError> {
let request = GetAlbumTracksRequest {
album_id: album_id.to_string(),
provider: Provider::Unspecified as i32,
};
let response = self.client.get_album_tracks(request).await?;
Ok(response.into_inner())
}
pub async fn sync_artist(
&mut self,
name: &str,
) -> Result<SyncArtistResponse, MetadataClientError> {
let request = SyncArtistRequest {
target: Some(sync_artist_request::Target::Name(name.to_string())),
provider: Provider::Musicbrainz as i32,
};
let response = self.client.sync_artist(request).await?;
Ok(response.into_inner())
}
}
-7
View File
@@ -1,7 +0,0 @@
mod client;
pub use client::{MetadataClient, MetadataClientError};
pub mod proto {
tonic::include_proto!("metadata.v1");
}
-31
View File
@@ -1,31 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Track {
pub id: Uuid,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration_secs: u32,
}
#[derive(Debug, Deserialize)]
pub struct CreateTrack {
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration_secs: u32,
}
impl From<CreateTrack> for Track {
fn from(input: CreateTrack) -> Self {
Self {
id: Uuid::new_v4(),
title: input.title,
artist: input.artist,
album: input.album,
duration_secs: input.duration_secs,
}
}
}
-211
View File
@@ -1,211 +0,0 @@
use sqlx::{postgres::PgPoolOptions, FromRow, PgPool};
use uuid::Uuid;
use crate::metadata::proto::{Album, Artist};
#[derive(Clone)]
pub struct DbService {
pool: PgPool,
}
impl DbService {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
Ok(Self { pool })
}
pub async fn upsert_artist_metadata(&self, artist: &Artist) -> Result<Uuid, sqlx::Error> {
let id = Uuid::parse_str(&artist.id).unwrap_or_else(|_| Uuid::new_v4());
let genres: serde_json::Value = serde_json::json!(artist
.genres
.iter()
.map(|g| serde_json::json!({"id": g.id, "name": g.name}))
.collect::<Vec<_>>());
let links: serde_json::Value = serde_json::json!(artist
.external_ids
.iter()
.map(
|e| serde_json::json!({"source": e.source, "source_id": e.source_id, "url": e.url})
)
.collect::<Vec<_>>());
let row: (Uuid,) = sqlx::query_as(
r#"
INSERT INTO artist_metadata (
id, foreign_artist_id, name, sort_name, disambiguation,
artist_type, status, overview, genres, links, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (foreign_artist_id) DO UPDATE SET
name = EXCLUDED.name,
sort_name = EXCLUDED.sort_name,
artist_type = EXCLUDED.artist_type,
overview = EXCLUDED.overview,
genres = EXCLUDED.genres,
links = EXCLUDED.links,
updated_at = NOW()
RETURNING id
"#,
)
.bind(id)
.bind(&artist.id)
.bind(&artist.name)
.bind(&artist.sort_name)
.bind(&artist.description)
.bind(&artist.artist_type)
.bind("active")
.bind(&artist.description)
.bind(&genres)
.bind(&links)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn upsert_album(
&self,
album: &Album,
artist_metadata_id: Uuid,
) -> Result<Uuid, sqlx::Error> {
let id = Uuid::parse_str(&album.id).unwrap_or_else(|_| Uuid::new_v4());
let genres: serde_json::Value = serde_json::json!(album
.genres
.iter()
.map(|g| serde_json::json!({"id": g.id, "name": g.name}))
.collect::<Vec<_>>());
let images: serde_json::Value = serde_json::json!([]);
let release_date = chrono::NaiveDate::parse_from_str(&album.release_date, "%Y-%m-%d").ok();
let clean_title = album
.title
.to_lowercase()
.replace(|c: char| !c.is_alphanumeric(), "");
let row: (Uuid,) = sqlx::query_as(
r#"
INSERT INTO albums (
id, artist_metadata_id, foreign_album_id, title, clean_title,
overview, album_type, release_date, images, genres
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (foreign_album_id) DO UPDATE SET
title = EXCLUDED.title,
album_type = EXCLUDED.album_type,
release_date = EXCLUDED.release_date,
genres = EXCLUDED.genres
RETURNING id
"#,
)
.bind(id)
.bind(artist_metadata_id)
.bind(&album.id)
.bind(&album.title)
.bind(&clean_title)
.bind("")
.bind(&album.album_type)
.bind(release_date)
.bind(&images)
.bind(&genres)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn list_artists(&self) -> Result<Vec<ArtistMetadataRow>, sqlx::Error> {
sqlx::query_as(
r#"
SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at
FROM artist_metadata
ORDER BY name
"#,
)
.fetch_all(&self.pool)
.await
}
pub async fn list_albums_by_artist(
&self,
artist_metadata_id: Uuid,
) -> Result<Vec<AlbumRow>, sqlx::Error> {
sqlx::query_as(
r#"
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
FROM albums
WHERE artist_metadata_id = $1
ORDER BY release_date DESC NULLS LAST
"#,
)
.bind(artist_metadata_id)
.fetch_all(&self.pool)
.await
}
pub async fn list_all_albums(&self) -> Result<Vec<AlbumWithArtistRow>, sqlx::Error> {
sqlx::query_as(
r#"
SELECT
a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at,
am.id as artist_id, am.name as artist_name
FROM albums a
JOIN artist_metadata am ON a.artist_metadata_id = am.id
ORDER BY a.added_at DESC
"#,
)
.fetch_all(&self.pool)
.await
}
pub async fn count_artists(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM artist_metadata")
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn count_albums(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM albums")
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
#[derive(Debug, serde::Serialize, FromRow)]
pub struct ArtistMetadataRow {
pub id: Uuid,
pub foreign_artist_id: Option<String>,
pub name: String,
pub sort_name: Option<String>,
pub artist_type: Option<String>,
pub genres: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize, FromRow)]
pub struct AlbumRow {
pub id: Uuid,
pub artist_metadata_id: Uuid,
pub foreign_album_id: Option<String>,
pub title: String,
pub album_type: Option<String>,
pub release_date: Option<chrono::NaiveDate>,
pub monitored: bool,
pub added_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize, FromRow)]
pub struct AlbumWithArtistRow {
pub id: Uuid,
pub foreign_album_id: Option<String>,
pub title: String,
pub album_type: Option<String>,
pub release_date: Option<chrono::NaiveDate>,
pub monitored: bool,
pub added_at: chrono::DateTime<chrono::Utc>,
pub artist_id: Uuid,
pub artist_name: String,
}
-254
View File
@@ -1,254 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::indexer::SearchResult;
use super::{DbService, IndexerService, MetadataService, TorrentService};
#[derive(Debug, Deserialize)]
pub struct SyncOptions {
pub artist: String,
pub album: Option<String>,
pub download: bool,
pub store: bool,
}
#[derive(Debug, Serialize)]
pub struct SyncResult {
pub artist_id: String,
pub artist_name: String,
pub total_albums: usize,
pub albums_stored: usize,
pub albums_downloaded: usize,
pub albums_no_results: usize,
pub albums_failed: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub results: Vec<AlbumSyncResult>,
}
#[derive(Debug, Serialize)]
pub struct AlbumSyncResult {
pub album_id: String,
pub album_title: String,
pub stored: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_status: Option<DownloadStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub torrent_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indexer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DownloadStatus {
Added,
NoResults,
Failed,
Skipped,
}
struct DownloadResult {
status: DownloadStatus,
torrent_hash: Option<String>,
indexer: Option<String>,
error: Option<String>,
}
pub struct DownloadService;
impl DownloadService {
pub async fn sync(
options: SyncOptions,
metadata: &MetadataService,
indexers: &IndexerService,
torrent: &TorrentService,
db: Option<&DbService>,
) -> Result<SyncResult, String> {
let search_result = metadata
.search_artists(&options.artist, Some(1), None)
.await
.map_err(|e| format!("metadata search failed: {}", e))?;
let artist = search_result
.artists
.first()
.ok_or_else(|| format!("artist '{}' not found", options.artist))?;
let artist_metadata_id = if options.store {
if let Some(db) = db {
match db.upsert_artist_metadata(artist).await {
Ok(id) => {
tracing::info!("stored artist metadata: {} ({})", artist.name, id);
Some(id)
}
Err(e) => {
tracing::warn!("failed to store artist metadata: {}", e);
None
}
}
} else {
None
}
} else {
None
};
let albums_response = metadata
.get_artist_albums(&artist.id, Some(500), None)
.await
.map_err(|e| format!("failed to get albums: {}", e))?;
let albums_to_process: Vec<_> = if let Some(ref album_filter) = options.album {
let filter_lower = album_filter.to_lowercase();
albums_response
.albums
.iter()
.filter(|a| a.title.to_lowercase().contains(&filter_lower))
.collect()
} else {
albums_response.albums.iter().collect()
};
let mut results = Vec::new();
let mut albums_stored = 0;
let mut albums_downloaded = 0;
let mut albums_no_results = 0;
let mut albums_failed = 0;
for album in albums_to_process.iter() {
let stored = if options.store {
if let (Some(db), Some(artist_id)) = (db, artist_metadata_id) {
match db.upsert_album(album, artist_id).await {
Ok(_) => {
albums_stored += 1;
true
}
Err(e) => {
tracing::warn!("failed to store album {}: {}", album.title, e);
false
}
}
} else {
false
}
} else {
false
};
let (download_status, torrent_hash, indexer, error) = if options.download {
let year = album
.release_date
.split('-')
.next()
.and_then(|y| y.parse().ok());
let dl_result =
Self::download_album(&artist.name, &album.title, year, indexers, torrent).await;
match dl_result.status {
DownloadStatus::Added => albums_downloaded += 1,
DownloadStatus::NoResults => albums_no_results += 1,
DownloadStatus::Failed | DownloadStatus::Skipped => albums_failed += 1,
}
(
Some(dl_result.status),
dl_result.torrent_hash,
dl_result.indexer,
dl_result.error,
)
} else {
(None, None, None, None)
};
results.push(AlbumSyncResult {
album_id: album.id.clone(),
album_title: album.title.clone(),
stored,
download_status,
torrent_hash,
indexer,
error,
});
}
Ok(SyncResult {
artist_id: artist.id.clone(),
artist_name: artist.name.clone(),
total_albums: albums_to_process.len(),
albums_stored,
albums_downloaded,
albums_no_results,
albums_failed,
results,
})
}
async fn download_album(
artist_name: &str,
album_title: &str,
year: Option<u32>,
indexers: &IndexerService,
torrent: &TorrentService,
) -> DownloadResult {
let criteria = crate::indexer::MusicSearchCriteria {
artist: artist_name.to_string(),
album: Some(album_title.to_string()),
year,
limit: 20,
offset: 0,
};
let search_results = match indexers.search(&criteria, None).await {
Ok(r) => r,
Err(e) => {
return DownloadResult {
status: DownloadStatus::Failed,
torrent_hash: None,
indexer: None,
error: Some(format!("indexer search failed: {}", e)),
};
}
};
if search_results.is_empty() {
return DownloadResult {
status: DownloadStatus::NoResults,
torrent_hash: None,
indexer: None,
error: None,
};
}
let best = Self::select_best_result(&search_results);
match torrent.add_torrent_url(&best.download_url, None).await {
Ok(()) => DownloadResult {
status: DownloadStatus::Added,
torrent_hash: best.infohash.clone(),
indexer: Some(best.indexer.clone()),
error: None,
},
Err(e) => DownloadResult {
status: DownloadStatus::Failed,
torrent_hash: None,
indexer: Some(best.indexer.clone()),
error: Some(format!("failed to add torrent: {}", e)),
},
}
}
fn select_best_result(results: &[SearchResult]) -> &SearchResult {
results
.iter()
.max_by_key(|r| {
let seeders = r.seeders.unwrap_or(0);
let is_flac = r.title.to_lowercase().contains("flac");
let score = seeders as i64 + if is_flac { 1000 } else { 0 };
score
})
.unwrap()
}
}
-105
View File
@@ -1,105 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::config::{IndexerConfig, IndexerType};
use crate::indexer::{Indexer, IndexerError, MusicSearchCriteria, SearchResult, TorznabIndexer};
pub struct IndexerService {
indexers: HashMap<String, Arc<dyn Indexer>>,
}
impl IndexerService {
pub fn new() -> Self {
Self {
indexers: HashMap::new(),
}
}
pub fn from_config(configs: &[IndexerConfig]) -> Result<Self, IndexerError> {
let mut service = Self::new();
for config in configs {
let torznab_url = Self::build_torznab_url(&config.url, config.indexer_type);
let indexer = TorznabIndexer::new(&config.name, &torznab_url, &config.api_key)?;
service.add_indexer(Arc::new(indexer));
}
Ok(service)
}
fn build_torznab_url(base_url: &str, indexer_type: IndexerType) -> String {
let base = base_url.trim_end_matches('/');
match indexer_type {
IndexerType::Jackett => format!("{}/api/v2.0/indexers/all/results/torznab/", base),
IndexerType::Prowlarr => format!("{}/api/v1/indexer/all/torznab", base),
IndexerType::Torznab => base_url.to_string(),
}
}
pub fn add_indexer(&mut self, indexer: Arc<dyn Indexer>) {
self.indexers.insert(indexer.name().to_string(), indexer);
}
pub fn get_indexer(&self, name: &str) -> Option<Arc<dyn Indexer>> {
self.indexers.get(name).cloned()
}
pub fn list_indexers(&self) -> Vec<IndexerInfo> {
self.indexers
.values()
.map(|i| IndexerInfo {
name: i.name().to_string(),
supports_music: i.supports_music_search(),
})
.collect()
}
pub async fn search(
&self,
criteria: &MusicSearchCriteria,
indexer_name: Option<&str>,
) -> Result<Vec<SearchResult>, IndexerError> {
match indexer_name {
Some(name) => {
let indexer = self.indexers.get(name).ok_or_else(|| {
IndexerError::Unavailable(format!("indexer not found: {}", name))
})?;
indexer.search(criteria).await
}
None => {
let mut all_results = Vec::new();
for indexer in self.indexers.values() {
if indexer.supports_music_search() {
match indexer.search(criteria).await {
Ok(results) => all_results.extend(results),
Err(e) => {
tracing::warn!("indexer {} failed: {}", indexer.name(), e);
}
}
}
}
Ok(all_results)
}
}
}
pub async fn test_indexer(&self, name: &str) -> Result<(), IndexerError> {
let indexer = self
.indexers
.get(name)
.ok_or_else(|| IndexerError::Unavailable(format!("indexer not found: {}", name)))?;
indexer.test_connection().await
}
}
impl Default for IndexerService {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct IndexerInfo {
pub name: String,
pub supports_music: bool,
}
-92
View File
@@ -1,92 +0,0 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::metadata::{MetadataClient, MetadataClientError};
pub struct MetadataService {
client: Option<Arc<Mutex<MetadataClient>>>,
endpoint: String,
}
impl MetadataService {
pub fn new(endpoint: &str) -> Self {
Self {
client: None,
endpoint: endpoint.to_string(),
}
}
pub async fn connect(&mut self) -> Result<(), MetadataClientError> {
let client = MetadataClient::connect(&self.endpoint).await?;
self.client = Some(Arc::new(Mutex::new(client)));
Ok(())
}
pub fn is_connected(&self) -> bool {
self.client.is_some()
}
fn client(&self) -> Result<Arc<Mutex<MetadataClient>>, MetadataClientError> {
self.client
.clone()
.ok_or_else(|| MetadataClientError::ConnectionFailed("not connected".into()))
}
pub async fn search_artists(
&self,
query: &str,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<crate::metadata::proto::SearchArtistsResponse, MetadataClientError> {
let client = self.client()?;
let mut guard = client.lock().await;
guard.search_artists(query, limit, offset).await
}
pub async fn get_artist(
&self,
id: &str,
) -> Result<crate::metadata::proto::Artist, MetadataClientError> {
let client = self.client()?;
let mut guard = client.lock().await;
guard.get_artist(id).await
}
pub async fn get_artist_albums(
&self,
artist_id: &str,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<crate::metadata::proto::GetArtistAlbumsResponse, MetadataClientError> {
let client = self.client()?;
let mut guard = client.lock().await;
guard.get_artist_albums(artist_id, limit, offset).await
}
pub async fn get_album(
&self,
id: &str,
) -> Result<crate::metadata::proto::Album, MetadataClientError> {
let client = self.client()?;
let mut guard = client.lock().await;
guard.get_album(id).await
}
pub async fn get_album_tracks(
&self,
album_id: &str,
) -> Result<crate::metadata::proto::GetAlbumTracksResponse, MetadataClientError> {
let client = self.client()?;
let mut guard = client.lock().await;
guard.get_album_tracks(album_id).await
}
pub async fn sync_artist(
&self,
name: &str,
) -> Result<crate::metadata::proto::SyncArtistResponse, MetadataClientError> {
let client = self.client()?;
let mut guard = client.lock().await;
guard.sync_artist(name).await
}
}
-57
View File
@@ -1,57 +0,0 @@
mod db_service;
mod download_service;
mod indexer_service;
mod metadata_service;
mod torrent_service;
pub use db_service::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow, DbService};
pub use download_service::{DownloadService, DownloadStatus, SyncOptions, SyncResult};
pub use indexer_service::{IndexerInfo, IndexerService};
pub use metadata_service::MetadataService;
pub use torrent_service::TorrentService;
use uuid::Uuid;
use crate::models::Track;
#[derive(Default)]
pub struct Aggregator {
tracks: Vec<Track>,
}
impl Aggregator {
pub fn new() -> Self {
Self::default()
}
pub fn add_track(&mut self, track: Track) -> Track {
self.tracks.push(track.clone());
track
}
pub fn get_all(&self) -> &[Track] {
&self.tracks
}
pub fn get_by_id(&self, id: Uuid) -> Option<&Track> {
self.tracks.iter().find(|t| t.id == id)
}
pub fn search_by_artist(&self, artist: &str) -> Vec<&Track> {
let artist_lower = artist.to_lowercase();
self.tracks
.iter()
.filter(|t| t.artist.to_lowercase().contains(&artist_lower))
.collect()
}
pub fn delete(&mut self, id: Uuid) -> bool {
let len_before = self.tracks.len();
self.tracks.retain(|t| t.id != id);
self.tracks.len() != len_before
}
pub fn total_duration(&self) -> u32 {
self.tracks.iter().map(|t| t.duration_secs).sum()
}
}
-99
View File
@@ -1,99 +0,0 @@
use std::sync::Arc;
use crate::config::TorrentConfig;
use crate::torrent::{
QBittorrentClient, StubTorrentClient, TorrentClient, TorrentClientError, TorrentInfo,
};
pub struct TorrentService {
client: Option<Arc<dyn TorrentClient>>,
}
impl TorrentService {
pub fn new() -> Self {
Self { client: None }
}
pub async fn from_config(config: &TorrentConfig) -> Result<Self, TorrentClientError> {
match config {
TorrentConfig::QBittorrent {
url,
username,
password,
} => {
let mut client = QBittorrentClient::new(url, username, password)?;
client.connect().await?;
Ok(Self {
client: Some(Arc::new(client)),
})
}
TorrentConfig::Stub {
log_path,
save_path,
} => {
let mut client = StubTorrentClient::new(log_path, save_path);
client.connect().await?;
Ok(Self {
client: Some(Arc::new(client)),
})
}
TorrentConfig::None => Ok(Self::new()),
}
}
fn client(&self) -> Result<&Arc<dyn TorrentClient>, TorrentClientError> {
self.client
.as_ref()
.ok_or_else(|| TorrentClientError::ConnectionFailed("no client configured".into()))
}
pub async fn is_connected(&self) -> bool {
self.client.is_some()
}
pub async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
self.client()?.list_torrents().await
}
pub async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
self.client()?.get_torrent(hash).await
}
pub async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.client()?.add_torrent_url(url, save_path).await
}
pub async fn add_torrent_file(
&self,
data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.client()?.add_torrent_file(data, save_path).await
}
pub async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError> {
self.client()?.remove_torrent(hash, delete_files).await
}
pub async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.client()?.pause_torrent(hash).await
}
pub async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.client()?.resume_torrent(hash).await
}
}
impl Default for TorrentService {
fn default() -> Self {
Self::new()
}
}
-81
View File
@@ -1,81 +0,0 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TorrentClientError {
#[error("authentication failed")]
AuthenticationFailed,
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("torrent not found: {0}")]
TorrentNotFound(String),
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("unexpected error: {0}")]
Unexpected(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TorrentState {
Downloading,
Seeding,
Paused,
Queued,
Checking,
Error,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TorrentInfo {
pub hash: String,
pub name: String,
pub size: u64,
pub progress: f64,
pub download_speed: u64,
pub upload_speed: u64,
pub state: TorrentState,
pub save_path: String,
}
#[async_trait]
pub trait TorrentClient: Send + Sync {
async fn connect(&mut self) -> Result<(), TorrentClientError>;
async fn disconnect(&mut self) -> Result<(), TorrentClientError>;
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError>;
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError>;
async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError>;
async fn add_torrent_file(
&self,
torrent_data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError>;
async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError>;
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
}
-7
View File
@@ -1,7 +0,0 @@
mod client;
mod qbittorrent;
mod stub;
pub use client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
pub use qbittorrent::QBittorrentClient;
pub use stub::StubTorrentClient;
-253
View File
@@ -1,253 +0,0 @@
use async_trait::async_trait;
use reqwest::{multipart, Client};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::RwLock;
use url::Url;
use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
pub struct QBittorrentClient {
base_url: Url,
username: String,
password: String,
http: Client,
connected: Arc<RwLock<bool>>,
}
#[derive(Debug, Deserialize)]
struct QBTorrent {
hash: String,
name: String,
size: i64,
progress: f64,
dlspeed: i64,
upspeed: i64,
state: String,
save_path: String,
}
impl QBittorrentClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, TorrentClientError> {
let base_url =
Url::parse(base_url).map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let http = Client::builder().cookie_store(true).build()?;
Ok(Self {
base_url,
username: username.to_string(),
password: password.to_string(),
http,
connected: Arc::new(RwLock::new(false)),
})
}
fn api_url(&self, path: &str) -> String {
format!("{}api/v2{}", self.base_url, path)
}
fn map_state(state: &str) -> TorrentState {
match state {
"downloading" | "forcedDL" | "metaDL" | "allocating" => TorrentState::Downloading,
"uploading" | "forcedUP" | "stalledUP" => TorrentState::Seeding,
"pausedDL" | "pausedUP" => TorrentState::Paused,
"queuedDL" | "queuedUP" => TorrentState::Queued,
"checkingDL" | "checkingUP" | "checkingResumeData" => TorrentState::Checking,
"error" | "missingFiles" => TorrentState::Error,
_ => TorrentState::Unknown,
}
}
fn map_torrent(t: QBTorrent) -> TorrentInfo {
TorrentInfo {
hash: t.hash,
name: t.name,
size: t.size.max(0) as u64,
progress: t.progress,
download_speed: t.dlspeed.max(0) as u64,
upload_speed: t.upspeed.max(0) as u64,
state: Self::map_state(&t.state),
save_path: t.save_path,
}
}
async fn ensure_connected(&self) -> Result<(), TorrentClientError> {
let connected = *self.connected.read().await;
if !connected {
return Err(TorrentClientError::ConnectionFailed("not connected".into()));
}
Ok(())
}
}
#[async_trait]
impl TorrentClient for QBittorrentClient {
async fn connect(&mut self) -> Result<(), TorrentClientError> {
let params = [
("username", self.username.as_str()),
("password", self.password.as_str()),
];
let resp = self
.http
.post(self.api_url("/auth/login"))
.form(&params)
.send()
.await?;
let text = resp.text().await?;
if text == "Ok." {
*self.connected.write().await = true;
Ok(())
} else {
Err(TorrentClientError::AuthenticationFailed)
}
}
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
self.http.post(self.api_url("/auth/logout")).send().await?;
*self.connected.write().await = false;
Ok(())
}
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
self.ensure_connected().await?;
let resp = self.http.get(self.api_url("/torrents/info")).send().await?;
let torrents: Vec<QBTorrent> = resp.json().await?;
Ok(torrents.into_iter().map(Self::map_torrent).collect())
}
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
self.ensure_connected().await?;
let resp = self
.http
.get(self.api_url("/torrents/info"))
.query(&[("hashes", hash)])
.send()
.await?;
let torrents: Vec<QBTorrent> = resp.json().await?;
torrents
.into_iter()
.next()
.map(Self::map_torrent)
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
}
async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let mut form = multipart::Form::new().text("urls", url.to_string());
if let Some(path) = save_path {
form = form.text("savepath", path.to_string());
}
let resp = self
.http
.post(self.api_url("/torrents/add"))
.multipart(form)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(TorrentClientError::InvalidRequest(
resp.text().await.unwrap_or_default(),
))
}
}
async fn add_torrent_file(
&self,
torrent_data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let part = multipart::Part::bytes(torrent_data.to_vec())
.file_name("torrent.torrent")
.mime_str("application/x-bittorrent")
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let mut form = multipart::Form::new().part("torrents", part);
if let Some(path) = save_path {
form = form.text("savepath", path.to_string());
}
let resp = self
.http
.post(self.api_url("/torrents/add"))
.multipart(form)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(TorrentClientError::InvalidRequest(
resp.text().await.unwrap_or_default(),
))
}
}
async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let resp = self
.http
.post(self.api_url("/torrents/delete"))
.form(&[
("hashes", hash),
("deleteFiles", if delete_files { "true" } else { "false" }),
])
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
}
}
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
self.http
.post(self.api_url("/torrents/pause"))
.form(&[("hashes", hash)])
.send()
.await?;
Ok(())
}
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
self.http
.post(self.api_url("/torrents/resume"))
.form(&[("hashes", hash)])
.send()
.await?;
Ok(())
}
}
-228
View File
@@ -1,228 +0,0 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
pub enum StubRequest {
AddUrl {
url: String,
save_path: Option<String>,
timestamp: String,
},
AddFile {
size: usize,
save_path: Option<String>,
timestamp: String,
},
Remove {
hash: String,
delete_files: bool,
timestamp: String,
},
Pause {
hash: String,
timestamp: String,
},
Resume {
hash: String,
timestamp: String,
},
}
struct StubTorrent {
info: TorrentInfo,
}
pub struct StubTorrentClient {
torrents: Arc<RwLock<HashMap<String, StubTorrent>>>,
log_path: PathBuf,
save_path: String,
connected: bool,
}
impl StubTorrentClient {
pub fn new(log_path: impl Into<PathBuf>, save_path: impl Into<String>) -> Self {
Self {
torrents: Arc::new(RwLock::new(HashMap::new())),
log_path: log_path.into(),
save_path: save_path.into(),
connected: false,
}
}
fn log_request(&self, request: &StubRequest) {
use std::io::Write;
let json = serde_json::to_string_pretty(request).unwrap_or_default();
let entry = format!("{}\n---\n", json);
let result = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)
.and_then(|mut f| f.write_all(entry.as_bytes()));
if let Err(e) = result {
tracing::warn!("failed to write stub log: {}", e);
}
}
fn generate_hash(input: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
format!("{:016x}{:016x}{:08x}", hasher.finish(), hasher.finish(), 0)
}
fn timestamp() -> String {
chrono::Utc::now().to_rfc3339()
}
}
#[async_trait]
impl TorrentClient for StubTorrentClient {
async fn connect(&mut self) -> Result<(), TorrentClientError> {
self.connected = true;
tracing::info!("stub torrent client connected");
Ok(())
}
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
self.connected = false;
Ok(())
}
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
let torrents = self.torrents.read().await;
Ok(torrents.values().map(|t| t.info.clone()).collect())
}
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
let torrents = self.torrents.read().await;
torrents
.get(hash)
.map(|t| t.info.clone())
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
}
async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
let request = StubRequest::AddUrl {
url: url.to_string(),
save_path: save_path.map(String::from),
timestamp: Self::timestamp(),
};
self.log_request(&request);
let hash = Self::generate_hash(url);
let name = url.rsplit('/').next().unwrap_or("unknown").to_string();
let info = TorrentInfo {
hash: hash.clone(),
name,
size: 0,
progress: 0.0,
download_speed: 0,
upload_speed: 0,
state: TorrentState::Downloading,
save_path: save_path.unwrap_or(&self.save_path).to_string(),
};
let mut torrents = self.torrents.write().await;
torrents.insert(hash, StubTorrent { info });
Ok(())
}
async fn add_torrent_file(
&self,
torrent_data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
let request = StubRequest::AddFile {
size: torrent_data.len(),
save_path: save_path.map(String::from),
timestamp: Self::timestamp(),
};
self.log_request(&request);
let hash = Self::generate_hash(&format!("file:{}", torrent_data.len()));
let info = TorrentInfo {
hash: hash.clone(),
name: format!("torrent-{}.torrent", &hash[..8]),
size: torrent_data.len() as u64,
progress: 0.0,
download_speed: 0,
upload_speed: 0,
state: TorrentState::Downloading,
save_path: save_path.unwrap_or(&self.save_path).to_string(),
};
let mut torrents = self.torrents.write().await;
torrents.insert(hash, StubTorrent { info });
Ok(())
}
async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError> {
let request = StubRequest::Remove {
hash: hash.to_string(),
delete_files,
timestamp: Self::timestamp(),
};
self.log_request(&request);
let mut torrents = self.torrents.write().await;
torrents
.remove(hash)
.map(|_| ())
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
}
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
let request = StubRequest::Pause {
hash: hash.to_string(),
timestamp: Self::timestamp(),
};
self.log_request(&request);
let mut torrents = self.torrents.write().await;
if let Some(t) = torrents.get_mut(hash) {
t.info.state = TorrentState::Paused;
Ok(())
} else {
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
}
}
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
let request = StubRequest::Resume {
hash: hash.to_string(),
timestamp: Self::timestamp(),
};
self.log_request(&request);
let mut torrents = self.torrents.write().await;
if let Some(t) = torrents.get_mut(hash) {
t.info.state = TorrentState::Downloading;
Ok(())
} else {
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
}
}
}