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:
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
/target
|
|
||||||
/result
|
/result
|
||||||
.direnv/
|
.direnv/
|
||||||
config.yaml
|
config.yaml
|
||||||
|
/server
|
||||||
|
/vendor
|
||||||
|
pkg/metadatapb/
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
/nix/store/ykac3kn52hv5lqhffvg55zghgrvlgd0r-pre-commit-config.json
|
/nix/store/mchzk3cbvp456fd3nbajm120nrry3pls-pre-commit-config.json
|
||||||
Generated
-3445
File diff suppressed because it is too large
Load Diff
-37
@@ -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
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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,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
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }))
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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>,
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
mod client;
|
|
||||||
|
|
||||||
pub use client::{MetadataClient, MetadataClientError};
|
|
||||||
|
|
||||||
pub mod proto {
|
|
||||||
tonic::include_proto!("metadata.v1");
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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(¶ms)
|
|
||||||
.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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user