diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 6d65e35..0000000 --- a/cmd/server/main.go +++ /dev/null @@ -1,124 +0,0 @@ -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, - StorageBasePath: cfg.Storage.BasePath, - } - - 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") -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 2a12a98..0000000 --- a/go.mod +++ /dev/null @@ -1,43 +0,0 @@ -module github.com/fujin/music-agregator - -go 1.24.0 - -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.77.0 - google.golang.org/protobuf v1.36.10 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect - github.com/anacrolix/missinggo v1.3.0 // indirect - github.com/anacrolix/missinggo/v2 v2.10.0 // indirect - github.com/anacrolix/torrent v1.61.0 // indirect - github.com/huandu/xstrings v1.3.2 // indirect - 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/klauspost/cpuid/v2 v2.2.3 // 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/minio/sha256-simd v1.0.0 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect - github.com/multiformats/go-multihash v0.2.3 // indirect - github.com/multiformats/go-varint v0.0.6 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/spaolacci/murmur3 v1.1.0 // indirect - golang.org/x/crypto v0.44.0 // indirect - golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect - lukechampine.com/blake3 v1.1.6 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 5642428..0000000 --- a/go.sum +++ /dev/null @@ -1,341 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= -crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= -github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI= -github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= -github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= -github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4= -github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0= -github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc= -github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= -github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= -github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= -github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= -github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y= -github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw= -github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc= -github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ= -github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY= -github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA= -github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY= -github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y= -github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg= -github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= -github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= -github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8= -github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8= -github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ= -github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= -github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= -github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -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/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= -github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= -github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= -github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= -github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= -github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= -github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= -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/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= -github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= -github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= -github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -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/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= -github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -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/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= -github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= -github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= -github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= -github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= -github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY= -github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= -github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -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/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= -github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= -github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= -github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -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= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -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/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -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/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -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/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -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/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -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= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= -lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= diff --git a/internal/api/handlers.go b/internal/api/handlers.go deleted file mode 100644 index c1692f8..0000000 --- a/internal/api/handlers.go +++ /dev/null @@ -1,37 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/fujin/music-agregator/internal/database" - "github.com/fujin/music-agregator/internal/metadata" - "github.com/fujin/music-agregator/internal/services" - "github.com/google/uuid" -) - -type Handlers struct { - IndexerService *services.IndexerService - TorrentService *services.TorrentService - MetadataClient *metadata.Client - DB *database.DB - StorageBasePath string -} - -func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -func parseUUID(s string) (uuid.UUID, error) { - return uuid.Parse(s) -} - -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}) -} diff --git a/internal/api/handlers_album.go b/internal/api/handlers_album.go deleted file mode 100644 index 77aa55a..0000000 --- a/internal/api/handlers_album.go +++ /dev/null @@ -1,99 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/fujin/music-agregator/internal/services" - "github.com/go-chi/chi/v5" -) - -func (h *Handlers) GetAlbum(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - albumIDStr := chi.URLParam(r, "id") - albumID, err := parseUUID(albumIDStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid album ID") - return - } - - album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID) - if err != nil { - writeError(w, http.StatusNotFound, "album not found") - return - } - - writeJSON(w, http.StatusOK, album) -} - -func (h *Handlers) EditAlbum(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - albumIDStr := chi.URLParam(r, "id") - albumID, err := parseUUID(albumIDStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid album ID") - return - } - - var update struct { - Monitored *bool `json:"monitored"` - } - if err := json.NewDecoder(r.Body).Decode(&update); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if update.Monitored != nil { - if err := h.DB.UpdateAlbumMonitored(r.Context(), albumID, *update.Monitored); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - if *update.Monitored { - hasFiles, _ := h.DB.HasTrackFiles(r.Context(), albumID) - if !hasFiles { - h.DB.AddToWantedAlbums(r.Context(), albumID) - } - } else { - h.DB.RemoveFromWantedAlbums(r.Context(), albumID) - } - } - - album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID) - if err != nil { - writeError(w, http.StatusNotFound, "album not found") - return - } - - writeJSON(w, http.StatusOK, album) -} - -func (h *Handlers) SearchAlbum(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - albumIDStr := chi.URLParam(r, "id") - albumID, err := parseUUID(albumIDStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid album ID") - return - } - - result, err := services.SearchAlbum(r.Context(), albumID, h.DB, h.IndexerService) - if err != nil { - writeError(w, http.StatusNotFound, "album not found") - return - } - - writeJSON(w, http.StatusOK, result) -} diff --git a/internal/api/handlers_artist.go b/internal/api/handlers_artist.go deleted file mode 100644 index 1fa2102..0000000 --- a/internal/api/handlers_artist.go +++ /dev/null @@ -1,219 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/fujin/music-agregator/internal/database" - "github.com/fujin/music-agregator/internal/services" - "github.com/go-chi/chi/v5" -) - -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) GetArtist(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artistID := chi.URLParam(r, "id") - if artistID == "" { - writeError(w, http.StatusBadRequest, "artist ID required") - return - } - - artist, err := h.DB.GetArtistByForeignID(r.Context(), artistID) - if err != nil { - writeError(w, http.StatusNotFound, "artist not found: "+artistID) - return - } - - writeJSON(w, http.StatusOK, artist) -} - -func (h *Handlers) EditArtist(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artistID := chi.URLParam(r, "id") - if artistID == "" { - writeError(w, http.StatusBadRequest, "artist ID required") - return - } - - var update database.ArtistUpdate - if err := json.NewDecoder(r.Body).Decode(&update); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - artist, err := h.DB.UpdateArtistByForeignID(r.Context(), artistID, update) - if err != nil { - writeError(w, http.StatusNotFound, "artist not found: "+artistID) - return - } - - writeJSON(w, http.StatusOK, artist) -} - -func (h *Handlers) DeleteArtist(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artistID := chi.URLParam(r, "id") - if artistID == "" { - writeError(w, http.StatusBadRequest, "artist ID required") - return - } - - deleted, err := h.DB.DeleteArtistByForeignID(r.Context(), artistID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - if !deleted { - writeError(w, http.StatusNotFound, "artist not found: "+artistID) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "deleted": true, - "message": "artist and related data deleted", - }) -} - -func (h *Handlers) RefreshArtist(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artistID := chi.URLParam(r, "id") - if artistID == "" { - writeError(w, http.StatusBadRequest, "artist ID required") - return - } - - result, err := services.RefreshArtist(r.Context(), artistID, h.MetadataClient, 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) BulkMonitorArtistAlbums(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artistID := chi.URLParam(r, "id") - if artistID == "" { - writeError(w, http.StatusBadRequest, "artist ID required") - return - } - - var req struct { - Monitored bool `json:"monitored"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - artist, err := h.DB.GetArtistMetadataByForeignID(r.Context(), artistID) - if err != nil { - writeError(w, http.StatusNotFound, "artist not found") - return - } - - updatedCount, err := h.DB.BulkUpdateAlbumsMonitored(r.Context(), artist.ID, req.Monitored) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - albums, _ := h.DB.ListAlbumsByArtist(r.Context(), artist.ID) - for _, album := range albums { - if req.Monitored { - hasFiles, _ := h.DB.HasTrackFiles(r.Context(), album.ID) - if !hasFiles { - h.DB.AddToWantedAlbums(r.Context(), album.ID) - } - } else { - h.DB.RemoveFromWantedAlbums(r.Context(), album.ID) - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "updated_count": updatedCount, - "monitored": req.Monitored, - }) -} - -func (h *Handlers) SearchArtistAlbums(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artistID := chi.URLParam(r, "id") - if artistID == "" { - writeError(w, http.StatusBadRequest, "artist ID required") - return - } - - result, err := services.SearchArtistAlbums(r.Context(), artistID, h.DB, h.IndexerService) - if err != nil { - writeError(w, http.StatusNotFound, "artist not found") - return - } - - writeJSON(w, http.StatusOK, result) -} diff --git a/internal/api/handlers_indexer.go b/internal/api/handlers_indexer.go deleted file mode 100644 index 41142a9..0000000 --- a/internal/api/handlers_indexer.go +++ /dev/null @@ -1,47 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/fujin/music-agregator/internal/indexer" -) - -func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) { - indexers := h.IndexerService.GetIndexers(r.Context()) - writeJSON(w, http.StatusOK, indexers) -} - -func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) { - var req 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"` - } - 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) -} diff --git a/internal/api/handlers_library.go b/internal/api/handlers_library.go deleted file mode 100644 index ee77a59..0000000 --- a/internal/api/handlers_library.go +++ /dev/null @@ -1,59 +0,0 @@ -package api - -import ( - "net/http" -) - -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, - }) -} diff --git a/internal/api/handlers_queue.go b/internal/api/handlers_queue.go deleted file mode 100644 index e9ebae8..0000000 --- a/internal/api/handlers_queue.go +++ /dev/null @@ -1,277 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/fujin/music-agregator/internal/services" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -func (h *Handlers) ListQueue(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - var status *string - if s := r.URL.Query().Get("status"); s != "" { - status = &s - } - - items, err := h.DB.ListDownloadQueue(r.Context(), status) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "items": items, - "total": len(items), - }) -} - -func (h *Handlers) GetQueueItem(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - idStr := chi.URLParam(r, "id") - id, err := parseUUID(idStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid ID") - return - } - - item, err := h.DB.GetDownloadQueueItem(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "queue item not found") - return - } - - writeJSON(w, http.StatusOK, item) -} - -func (h *Handlers) AddToQueue(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - var req struct { - Title string `json:"title"` - TorrentHash *string `json:"torrent_hash"` - Size int64 `json:"size"` - Indexer *string `json:"indexer"` - AlbumID *string `json:"album_id"` - ArtistID *string `json:"artist_id"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - var albumID, artistID *uuid.UUID - if req.AlbumID != nil { - if id, err := parseUUID(*req.AlbumID); err == nil { - albumID = &id - } - } - if req.ArtistID != nil { - if id, err := parseUUID(*req.ArtistID); err == nil { - artistID = &id - } - } - - id, err := h.DB.AddToDownloadQueue(r.Context(), req.Title, req.Size, req.TorrentHash, req.Indexer, albumID, artistID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - item, _ := h.DB.GetDownloadQueueItem(r.Context(), id) - writeJSON(w, http.StatusOK, item) -} - -func (h *Handlers) UpdateQueueItem(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - idStr := chi.URLParam(r, "id") - id, err := parseUUID(idStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid ID") - return - } - - var req struct { - Status *string `json:"status"` - ErrorMessage *string `json:"error_message"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.Status != nil { - if *req.Status == "failed" && req.ErrorMessage != nil { - services.HandleFailedDownload(r.Context(), h.DB, id, *req.ErrorMessage) - } else { - if err := h.DB.UpdateDownloadQueueStatus(r.Context(), id, *req.Status, req.ErrorMessage); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - if *req.Status == "completed" { - item, _ := h.DB.GetDownloadQueueItem(r.Context(), id) - if item != nil && item.AlbumID != nil { - h.DB.RemoveFromWantedAlbums(r.Context(), *item.AlbumID) - } - } - } - } - - item, err := h.DB.GetDownloadQueueItem(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "queue item not found") - return - } - - writeJSON(w, http.StatusOK, item) -} - -func (h *Handlers) DeleteQueueItem(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - idStr := chi.URLParam(r, "id") - id, err := parseUUID(idStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid ID") - return - } - - item, err := h.DB.GetDownloadQueueItem(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "queue item not found") - return - } - - if item.TorrentHash != nil && h.TorrentService.IsConfigured() { - h.TorrentService.RemoveTorrent(r.Context(), *item.TorrentHash, false) - } - - if err := h.DB.DeleteDownloadQueueItem(r.Context(), id); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) -} - -func (h *Handlers) SyncQueue(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - result, err := services.SyncDownloadQueue(r.Context(), h.DB, h.TorrentService) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, result) -} - -func (h *Handlers) BlocklistQueueItem(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - idStr := chi.URLParam(r, "id") - id, err := parseUUID(idStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid ID") - return - } - - result, err := services.BlocklistAndRemove(r.Context(), h.DB, h.TorrentService, id) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, result) -} - -func (h *Handlers) QueueStats(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - stats, err := h.DB.GetDownloadQueueStats(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, stats) -} - -func (h *Handlers) GetJobStatus(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - idStr := chi.URLParam(r, "id") - id, err := parseUUID(idStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid job ID") - return - } - - status, err := services.GetJobStatus(r.Context(), h.DB, h.TorrentService, id) - if err != nil { - writeError(w, http.StatusNotFound, "job not found") - return - } - - writeJSON(w, http.StatusOK, status) -} - -func (h *Handlers) ImportQueueItem(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - if h.StorageBasePath == "" { - writeError(w, http.StatusServiceUnavailable, "storage not configured") - return - } - - idStr := chi.URLParam(r, "id") - id, err := parseUUID(idStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid ID") - return - } - - result, err := services.ImportCompletedDownload(r.Context(), id, h.StorageBasePath, h.DB, h.TorrentService) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - writeJSON(w, http.StatusOK, result) -} diff --git a/internal/api/handlers_sync.go b/internal/api/handlers_sync.go deleted file mode 100644 index 0163f7c..0000000 --- a/internal/api/handlers_sync.go +++ /dev/null @@ -1,89 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/fujin/music-agregator/internal/services" -) - -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) AddToBlocklist(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - var req struct { - AlbumID string `json:"album_id"` - SourceTitle string `json:"source_title"` - GUID *string `json:"guid"` - TorrentHash *string `json:"torrent_hash"` - Indexer *string `json:"indexer"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - albumID, err := parseUUID(req.AlbumID) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid album_id") - return - } - - artistID, err := h.DB.GetArtistIDByAlbum(r.Context(), albumID) - if err != nil { - writeError(w, http.StatusNotFound, "album not found") - return - } - - if err := h.DB.AddToBlocklist(r.Context(), *artistID, albumID, req.SourceTitle, req.TorrentHash, req.Indexer); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "added": true, - }) -} diff --git a/internal/api/handlers_torrent.go b/internal/api/handlers_torrent.go deleted file mode 100644 index 52bb204..0000000 --- a/internal/api/handlers_torrent.go +++ /dev/null @@ -1,79 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" -) - -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) -} - -func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) { - var req struct { - URL string `json:"url"` - SavePath *string `json:"save_path,omitempty"` - } - 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"}) -} - -func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) { - hash := chi.URLParam(r, "hash") - - var req struct { - DeleteFiles bool `json:"delete_files"` - } - 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"}) -} diff --git a/internal/api/router.go b/internal/api/router.go deleted file mode 100644 index 301ca48..0000000 --- a/internal/api/router.go +++ /dev/null @@ -1,85 +0,0 @@ -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("/artists", func(r chi.Router) { - r.Get("/{id}", h.GetArtist) - r.Put("/{id}", h.EditArtist) - r.Post("/{id}/refresh", h.RefreshArtist) - r.Delete("/{id}", h.DeleteArtist) - r.Put("/{id}/albums/monitor", h.BulkMonitorArtistAlbums) - r.Post("/{id}/search", h.SearchArtistAlbums) - }) - - r.Route("/albums", func(r chi.Router) { - r.Get("/{id}", h.GetAlbum) - r.Put("/{id}", h.EditAlbum) - r.Post("/{id}/search", h.SearchAlbum) - }) - - r.Post("/blocklist", h.AddToBlocklist) - - r.Route("/queue", func(r chi.Router) { - r.Get("/", h.ListQueue) - r.Post("/", h.AddToQueue) - r.Post("/sync", h.SyncQueue) - r.Get("/stats", h.QueueStats) - r.Get("/{id}", h.GetQueueItem) - r.Put("/{id}", h.UpdateQueueItem) - r.Delete("/{id}", h.DeleteQueueItem) - r.Post("/{id}/blocklist", h.BlocklistQueueItem) - r.Post("/{id}/import", h.ImportQueueItem) - }) - - r.Route("/library", func(r chi.Router) { - r.Get("/artists", h.ListLibraryArtists) - r.Get("/albums", h.ListLibraryAlbums) - r.Get("/stats", h.LibraryStats) - }) - - r.Get("/job/{id}", h.GetJobStatus) - }) - - return r -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 905a5b9..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,101 +0,0 @@ -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"` - Storage StorageConfig `yaml:"storage"` -} - -type StorageConfig struct { - BasePath string `yaml:"base_path"` -} - -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" - } - - if cfg.Storage.BasePath == "" { - cfg.Storage.BasePath = "/music" - } - - return &cfg, nil -} diff --git a/internal/database/db.go b/internal/database/db.go deleted file mode 100644 index fd5268d..0000000 --- a/internal/database/db.go +++ /dev/null @@ -1,766 +0,0 @@ -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 -} - -func (db *DB) CountAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) (int64, error) { - var count int64 - err := db.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM albums WHERE artist_metadata_id = $1 - `, artistMetadataID).Scan(&count) - return count, err -} - -func (db *DB) TouchArtistUpdatedAt(ctx context.Context, artistMetadataID uuid.UUID) error { - _, err := db.pool.Exec(ctx, ` - UPDATE artist_metadata SET updated_at = NOW() WHERE id = $1 - `, artistMetadataID) - return err -} - -func (db *DB) DeleteArtistByForeignID(ctx context.Context, foreignArtistID string) (bool, error) { - result, err := db.pool.Exec(ctx, ` - DELETE FROM artist_metadata WHERE foreign_artist_id = $1 - `, foreignArtistID) - if err != nil { - return false, err - } - return result.RowsAffected() > 0, nil -} - -type ArtistRow struct { - ID uuid.UUID `json:"id"` - MetadataID uuid.UUID `json:"metadata_id"` - ForeignArtistID string `json:"foreign_artist_id"` - Name string `json:"name"` - QualityProfileID *uuid.UUID `json:"quality_profile_id"` - MetadataProfileID *uuid.UUID `json:"metadata_profile_id"` - RootFolderID *uuid.UUID `json:"root_folder_id"` - Path *string `json:"path"` - Monitored bool `json:"monitored"` - MonitorNewItems string `json:"monitor_new_items"` -} - -func (db *DB) UpsertArtist(ctx context.Context, metadataID uuid.UUID) (uuid.UUID, error) { - var existingID uuid.UUID - err := db.pool.QueryRow(ctx, ` - SELECT id FROM artists WHERE metadata_id = $1 - `, metadataID).Scan(&existingID) - if err == nil { - return existingID, nil - } - - var resultID uuid.UUID - err = db.pool.QueryRow(ctx, ` - INSERT INTO artists (metadata_id, monitored, monitor_new_items) - VALUES ($1, true, 'all') - RETURNING id - `, metadataID).Scan(&resultID) - return resultID, err -} - -func (db *DB) GetArtistByForeignID(ctx context.Context, foreignArtistID string) (*ArtistRow, error) { - var a ArtistRow - err := db.pool.QueryRow(ctx, ` - SELECT a.id, a.metadata_id, am.foreign_artist_id, am.name, - a.quality_profile_id, a.metadata_profile_id, a.root_folder_id, - a.path, a.monitored, a.monitor_new_items - FROM artists a - JOIN artist_metadata am ON a.metadata_id = am.id - WHERE am.foreign_artist_id = $1 - `, foreignArtistID).Scan( - &a.ID, &a.MetadataID, &a.ForeignArtistID, &a.Name, - &a.QualityProfileID, &a.MetadataProfileID, &a.RootFolderID, - &a.Path, &a.Monitored, &a.MonitorNewItems, - ) - if err != nil { - return nil, err - } - return &a, nil -} - -type ArtistUpdate struct { - QualityProfileID *string `json:"quality_profile_id"` - MetadataProfileID *string `json:"metadata_profile_id"` - RootFolderID *string `json:"root_folder_id"` - Path *string `json:"path"` - Monitored *bool `json:"monitored"` - MonitorNewItems *string `json:"monitor_new_items"` -} - -func (db *DB) UpdateArtistByForeignID(ctx context.Context, foreignArtistID string, update ArtistUpdate) (*ArtistRow, error) { - metadataRow, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID) - if err != nil { - return nil, err - } - - if update.Monitored != nil { - _, err = db.pool.Exec(ctx, ` - UPDATE artists SET monitored = $1 WHERE metadata_id = $2 - `, *update.Monitored, metadataRow.ID) - if err != nil { - return nil, err - } - } - - if update.Path != nil { - _, err = db.pool.Exec(ctx, ` - UPDATE artists SET path = $1 WHERE metadata_id = $2 - `, *update.Path, metadataRow.ID) - if err != nil { - return nil, err - } - } - - if update.QualityProfileID != nil { - var qpID *uuid.UUID - if *update.QualityProfileID != "" { - parsed, err := uuid.Parse(*update.QualityProfileID) - if err == nil { - qpID = &parsed - } - } - _, err = db.pool.Exec(ctx, ` - UPDATE artists SET quality_profile_id = $1 WHERE metadata_id = $2 - `, qpID, metadataRow.ID) - if err != nil { - return nil, err - } - } - - if update.RootFolderID != nil { - var rfID *uuid.UUID - if *update.RootFolderID != "" { - parsed, err := uuid.Parse(*update.RootFolderID) - if err == nil { - rfID = &parsed - } - } - _, err = db.pool.Exec(ctx, ` - UPDATE artists SET root_folder_id = $1 WHERE metadata_id = $2 - `, rfID, metadataRow.ID) - if err != nil { - return nil, err - } - } - - if update.MonitorNewItems != nil { - _, err = db.pool.Exec(ctx, ` - UPDATE artists SET monitor_new_items = $1 WHERE metadata_id = $2 - `, *update.MonitorNewItems, metadataRow.ID) - if err != nil { - return nil, err - } - } - - return db.GetArtistByForeignID(ctx, foreignArtistID) -} - -func (db *DB) GetArtistMetadataByForeignID(ctx context.Context, foreignArtistID string) (*ArtistMetadataRow, error) { - var a ArtistMetadataRow - err := db.pool.QueryRow(ctx, ` - SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at - FROM artist_metadata - WHERE foreign_artist_id = $1 - `, foreignArtistID).Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt) - if err != nil { - return nil, err - } - return &a, nil -} - -func (db *DB) GetAlbumByID(ctx context.Context, albumID uuid.UUID) (*AlbumRow, error) { - var a AlbumRow - err := db.pool.QueryRow(ctx, ` - SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at - FROM albums WHERE id = $1 - `, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt) - if err != nil { - return nil, err - } - return &a, nil -} - -type AlbumDetailRow 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"` - ArtistName string `json:"artist_name"` - ForeignArtistID *string `json:"foreign_artist_id"` -} - -func (db *DB) GetAlbumDetailByID(ctx context.Context, albumID uuid.UUID) (*AlbumDetailRow, error) { - var a AlbumDetailRow - err := db.pool.QueryRow(ctx, ` - SELECT a.id, a.artist_metadata_id, a.foreign_album_id, a.title, a.album_type, - a.release_date, a.monitored, a.added_at, am.name, am.foreign_artist_id - FROM albums a - JOIN artist_metadata am ON a.artist_metadata_id = am.id - WHERE a.id = $1 - `, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, - &a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistName, &a.ForeignArtistID) - if err != nil { - return nil, err - } - return &a, nil -} - -func (db *DB) UpdateAlbumMonitored(ctx context.Context, albumID uuid.UUID, monitored bool) error { - _, err := db.pool.Exec(ctx, ` - UPDATE albums SET monitored = $1 WHERE id = $2 - `, monitored, albumID) - return err -} - -func (db *DB) BulkUpdateAlbumsMonitored(ctx context.Context, artistMetadataID uuid.UUID, monitored bool) (int64, error) { - result, err := db.pool.Exec(ctx, ` - UPDATE albums SET monitored = $1 WHERE artist_metadata_id = $2 - `, monitored, artistMetadataID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -func (db *DB) GetMonitoredAlbumsByArtist(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 AND monitored = true - 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 -} - -type WantedAlbumRow struct { - ID uuid.UUID `json:"id"` - AlbumID uuid.UUID `json:"album_id"` - Priority int `json:"priority"` - SearchCount int `json:"search_count"` - LastSearchedAt *time.Time `json:"last_searched_at"` - AddedAt time.Time `json:"added_at"` -} - -func (db *DB) AddToWantedAlbums(ctx context.Context, albumID uuid.UUID) error { - _, err := db.pool.Exec(ctx, ` - INSERT INTO wanted_albums (album_id) - VALUES ($1) - ON CONFLICT (album_id) DO NOTHING - `, albumID) - return err -} - -func (db *DB) RemoveFromWantedAlbums(ctx context.Context, albumID uuid.UUID) error { - _, err := db.pool.Exec(ctx, ` - DELETE FROM wanted_albums WHERE album_id = $1 - `, albumID) - return err -} - -func (db *DB) IsAlbumWanted(ctx context.Context, albumID uuid.UUID) (bool, error) { - var count int64 - err := db.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM wanted_albums WHERE album_id = $1 - `, albumID).Scan(&count) - return count > 0, err -} - -func (db *DB) HasTrackFiles(ctx context.Context, albumID uuid.UUID) (bool, error) { - var count int64 - err := db.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM track_files WHERE album_id = $1 - `, albumID).Scan(&count) - return count > 0, err -} - -type BlocklistEntry struct { - ID uuid.UUID `json:"id"` - ArtistID uuid.UUID `json:"artist_id"` - AlbumID uuid.UUID `json:"album_id"` - SourceTitle string `json:"source_title"` - TorrentHash *string `json:"torrent_hash"` - Indexer *string `json:"indexer"` - Message *string `json:"message"` -} - -func (db *DB) AddToBlocklist(ctx context.Context, artistID, albumID uuid.UUID, sourceTitle string, torrentHash, indexer *string) error { - _, err := db.pool.Exec(ctx, ` - INSERT INTO blocklist (artist_id, album_id, source_title, torrent_hash, indexer) - VALUES ($1, $2, $3, $4, $5) - `, artistID, albumID, sourceTitle, torrentHash, indexer) - return err -} - -func (db *DB) IsBlocklisted(ctx context.Context, sourceTitle string, torrentHash *string) (bool, error) { - var count int64 - if torrentHash != nil && *torrentHash != "" { - err := db.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM blocklist WHERE source_title = $1 OR torrent_hash = $2 - `, sourceTitle, *torrentHash).Scan(&count) - return count > 0, err - } - err := db.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM blocklist WHERE source_title = $1 - `, sourceTitle).Scan(&count) - return count > 0, err -} - -func (db *DB) ListBlocklist(ctx context.Context) ([]BlocklistEntry, error) { - rows, err := db.pool.Query(ctx, ` - SELECT id, artist_id, album_id, source_title, torrent_hash, indexer, message - FROM blocklist ORDER BY date DESC - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var entries []BlocklistEntry - for rows.Next() { - var e BlocklistEntry - err := rows.Scan(&e.ID, &e.ArtistID, &e.AlbumID, &e.SourceTitle, &e.TorrentHash, &e.Indexer, &e.Message) - if err != nil { - return nil, err - } - entries = append(entries, e) - } - return entries, nil -} - -func (db *DB) GetArtistIDByAlbum(ctx context.Context, albumID uuid.UUID) (*uuid.UUID, error) { - var artistID uuid.UUID - err := db.pool.QueryRow(ctx, ` - SELECT ar.id FROM artists ar - JOIN artist_metadata am ON ar.metadata_id = am.id - JOIN albums a ON a.artist_metadata_id = am.id - WHERE a.id = $1 - `, albumID).Scan(&artistID) - if err != nil { - return nil, err - } - return &artistID, nil -} - -type DownloadQueueRow struct { - ID uuid.UUID `json:"id"` - ArtistID *uuid.UUID `json:"artist_id"` - AlbumID *uuid.UUID `json:"album_id"` - DownloadID *string `json:"download_id"` - Title string `json:"title"` - Size int64 `json:"size"` - SizeLeft int64 `json:"size_left"` - Status string `json:"status"` - Progress float32 `json:"progress"` - ErrorMessage *string `json:"error_message"` - Protocol string `json:"protocol"` - Indexer *string `json:"indexer"` - DownloadClient *string `json:"download_client"` - TorrentHash *string `json:"torrent_hash"` - OutputPath *string `json:"output_path"` - AddedAt time.Time `json:"added_at"` - CompletedAt *time.Time `json:"completed_at"` -} - -func (db *DB) AddToDownloadQueue(ctx context.Context, title string, size int64, torrentHash, indexer *string, albumID, artistID *uuid.UUID) (uuid.UUID, error) { - var id uuid.UUID - err := db.pool.QueryRow(ctx, ` - INSERT INTO download_queue (title, size, torrent_hash, indexer, album_id, artist_id, status) - VALUES ($1, $2, $3, $4, $5, $6, 'queued') - RETURNING id - `, title, size, torrentHash, indexer, albumID, artistID).Scan(&id) - return id, err -} - -func (db *DB) GetDownloadQueueItem(ctx context.Context, id uuid.UUID) (*DownloadQueueRow, error) { - var row DownloadQueueRow - err := db.pool.QueryRow(ctx, ` - SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, - error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at - FROM download_queue WHERE id = $1 - `, id).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size, - &row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer, - &row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt) - if err != nil { - return nil, err - } - return &row, nil -} - -func (db *DB) ListDownloadQueue(ctx context.Context, status *string) ([]DownloadQueueRow, error) { - var rows []DownloadQueueRow - var query string - var args []any - - if status != nil { - query = ` - SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, - error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at - FROM download_queue WHERE status = $1 ORDER BY added_at DESC - ` - args = []any{*status} - } else { - query = ` - SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, - error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at - FROM download_queue ORDER BY added_at DESC - ` - } - - dbRows, err := db.pool.Query(ctx, query, args...) - if err != nil { - return nil, err - } - defer dbRows.Close() - - for dbRows.Next() { - var row DownloadQueueRow - err := dbRows.Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size, - &row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer, - &row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt) - if err != nil { - return nil, err - } - rows = append(rows, row) - } - return rows, nil -} - -func (db *DB) UpdateDownloadQueueStatus(ctx context.Context, id uuid.UUID, status string, errorMessage *string) error { - if status == "completed" { - _, err := db.pool.Exec(ctx, ` - UPDATE download_queue SET status = $1, completed_at = NOW() WHERE id = $2 - `, status, id) - return err - } - if errorMessage != nil { - _, err := db.pool.Exec(ctx, ` - UPDATE download_queue SET status = $1, error_message = $2 WHERE id = $3 - `, status, *errorMessage, id) - return err - } - _, err := db.pool.Exec(ctx, ` - UPDATE download_queue SET status = $1 WHERE id = $2 - `, status, id) - return err -} - -func (db *DB) UpdateDownloadQueueProgress(ctx context.Context, id uuid.UUID, progress float32, sizeLeft int64, status string) error { - _, err := db.pool.Exec(ctx, ` - UPDATE download_queue SET progress = $1, size_left = $2, status = $3 WHERE id = $4 - `, progress, sizeLeft, status, id) - return err -} - -func (db *DB) UpdateDownloadQueueHash(ctx context.Context, id uuid.UUID, hash string) error { - _, err := db.pool.Exec(ctx, `UPDATE download_queue SET torrent_hash = $1 WHERE id = $2`, hash, id) - return err -} - -func (db *DB) DeleteDownloadQueueItem(ctx context.Context, id uuid.UUID) error { - _, err := db.pool.Exec(ctx, `DELETE FROM download_queue WHERE id = $1`, id) - return err -} - -func (db *DB) GetDownloadQueueByTorrentHash(ctx context.Context, hash string) (*DownloadQueueRow, error) { - var row DownloadQueueRow - err := db.pool.QueryRow(ctx, ` - SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, - error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at - FROM download_queue WHERE torrent_hash = $1 - `, hash).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size, - &row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer, - &row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt) - if err != nil { - return nil, err - } - return &row, nil -} - -type DownloadQueueStats struct { - Total int64 `json:"total"` - Downloading int64 `json:"downloading"` - Queued int64 `json:"queued"` - Completed int64 `json:"completed"` - Failed int64 `json:"failed"` -} - -func (db *DB) GetDownloadQueueStats(ctx context.Context) (*DownloadQueueStats, error) { - var stats DownloadQueueStats - err := db.pool.QueryRow(ctx, ` - SELECT - COUNT(*) as total, - COUNT(*) FILTER (WHERE status = 'downloading') as downloading, - COUNT(*) FILTER (WHERE status = 'queued') as queued, - COUNT(*) FILTER (WHERE status = 'completed') as completed, - COUNT(*) FILTER (WHERE status = 'failed') as failed - FROM download_queue - `).Scan(&stats.Total, &stats.Downloading, &stats.Queued, &stats.Completed, &stats.Failed) - return &stats, err -} diff --git a/internal/indexer/search.go b/internal/indexer/search.go deleted file mode 100644 index 8ecbcff..0000000 --- a/internal/indexer/search.go +++ /dev/null @@ -1,54 +0,0 @@ -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"` -} diff --git a/internal/indexer/torznab.go b/internal/indexer/torznab.go deleted file mode 100644 index 6f2dfdd..0000000 --- a/internal/indexer/torznab.go +++ /dev/null @@ -1,289 +0,0 @@ -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, " 0 { - log.Info().Int("blocked", blockedCount).Msg("[ALBUM_SEARCH] filtered blocklisted results") - } - - sort.Slice(rankedResults, func(i, j int) bool { - return rankedResults[i].Score > rankedResults[j].Score - }) - - if len(rankedResults) > 0 { - best := rankedResults[0] - seeders := 0 - if best.Seeders != nil { - seeders = *best.Seeders - } - log.Info(). - Str("title", best.Title). - Str("quality", best.Quality). - Float64("score", best.Score). - Int("seeders", seeders). - Msg("[ALBUM_SEARCH] best result") - } - - log.Info().Int("total_results", len(rankedResults)).Msg("[ALBUM_SEARCH] search completed") - - return &AlbumSearchResult{ - AlbumID: albumID.String(), - AlbumTitle: album.Title, - ArtistName: album.ArtistName, - Results: rankedResults, - TotalResults: len(rankedResults), - }, nil -} - -func detectQuality(title string) string { - titleLower := strings.ToLower(title) - - if strings.Contains(titleLower, "flac") || strings.Contains(titleLower, "lossless") { - return "FLAC" - } - if strings.Contains(titleLower, "24bit") || strings.Contains(titleLower, "24-bit") || strings.Contains(titleLower, "hi-res") { - return "FLAC-24bit" - } - if strings.Contains(titleLower, "320") || strings.Contains(titleLower, "mp3-320") { - return "MP3-320" - } - if strings.Contains(titleLower, "v0") || strings.Contains(titleLower, "vbr") { - return "MP3-VBR" - } - if strings.Contains(titleLower, "mp3") { - return "MP3" - } - if strings.Contains(titleLower, "aac") { - return "AAC" - } - if strings.Contains(titleLower, "ogg") { - return "OGG" - } - return "Unknown" -} - -func calculateScore(quality string, seeders *int) float64 { - var score float64 - - switch quality { - case "FLAC-24bit": - score = 1000 - case "FLAC": - score = 900 - case "MP3-320": - score = 700 - case "MP3-VBR": - score = 600 - case "MP3": - score = 500 - case "AAC": - score = 400 - case "OGG": - score = 350 - default: - score = 100 - } - - if seeders != nil && *seeders > 0 { - score += float64(*seeders) * 0.1 - if *seeders > 100 { - score += 50 - } - } - - return score -} - -type ArtistSearchResult struct { - ArtistID string `json:"artist_id"` - ArtistName string `json:"artist_name"` - AlbumsSearched int `json:"albums_searched"` - Results []AlbumBriefResult `json:"results"` -} - -type AlbumBriefResult struct { - AlbumID string `json:"album_id"` - AlbumTitle string `json:"album_title"` - ResultsCount int `json:"results_count"` -} - -func SearchArtistAlbums( - ctx context.Context, - foreignArtistID string, - db *database.DB, - indexerService *IndexerService, -) (*ArtistSearchResult, error) { - artist, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID) - if err != nil { - return nil, err - } - - albums, err := db.GetMonitoredAlbumsByArtist(ctx, artist.ID) - if err != nil { - return nil, err - } - - var results []AlbumBriefResult - for _, album := range albums { - searchResult, err := SearchAlbum(ctx, album.ID, db, indexerService) - if err != nil { - results = append(results, AlbumBriefResult{ - AlbumID: album.ID.String(), - AlbumTitle: album.Title, - ResultsCount: 0, - }) - continue - } - - results = append(results, AlbumBriefResult{ - AlbumID: album.ID.String(), - AlbumTitle: album.Title, - ResultsCount: searchResult.TotalResults, - }) - } - - return &ArtistSearchResult{ - ArtistID: foreignArtistID, - ArtistName: artist.Name, - AlbumsSearched: len(albums), - Results: results, - }, nil -} diff --git a/internal/services/download.go b/internal/services/download.go deleted file mode 100644 index 614071e..0000000 --- a/internal/services/download.go +++ /dev/null @@ -1,536 +0,0 @@ -package services - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/anacrolix/torrent/metainfo" - "github.com/fujin/music-agregator/internal/database" - "github.com/fujin/music-agregator/internal/indexer" - "github.com/fujin/music-agregator/internal/metadata" - "github.com/google/uuid" - "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"` - JobID *string `json:"job_id,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 - queueID *string -} - -type downloadContext struct { - artistName string - albumTitle string - year *uint32 - artistID *uuid.UUID - albumID *uuid.UUID - indexerService *IndexerService - torrentService *TorrentService - db *database.DB -} - -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") - - if _, err := db.UpsertArtist(ctx, id); err != nil { - log.Warn().Err(err).Str("artist", artist.Name).Msg("failed to create artist library entry") - } - } - } - - 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, jobID *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 - } - } - } - - var artistUUID, albumUUID *uuid.UUID - if artistMetadataID != nil { - if id, err := uuid.Parse(*artistMetadataID); err == nil { - artistUUID = &id - if artistRow, err := db.GetArtistByForeignID(ctx, artist.Id); err == nil { - artistUUID = &artistRow.ID - } - } - } - if albumID, err := uuid.Parse(album.Id); err == nil { - if albumRow, err := db.GetAlbumByID(ctx, albumID); err == nil { - albumUUID = &albumRow.ID - } - } - - dlCtx := &downloadContext{ - artistName: artist.Name, - albumTitle: album.Title, - year: year, - artistID: artistUUID, - albumID: albumUUID, - indexerService: indexerService, - torrentService: torrentService, - db: db, - } - - dlResult := downloadAlbum(ctx, dlCtx) - downloadStatus = &dlResult.status - torrentHash = dlResult.torrentHash - indexerName = dlResult.indexer - dlError = dlResult.err - jobID = dlResult.queueID - - 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, - JobID: jobID, - 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, dlCtx *downloadContext) downloadResult { - albumStr := dlCtx.albumTitle - criteria := &indexer.MusicSearchCriteria{ - Artist: dlCtx.artistName, - Album: &albumStr, - Year: dlCtx.year, - Limit: 20, - Offset: 0, - } - - log.Info(). - Str("artist", dlCtx.artistName). - Str("album", dlCtx.albumTitle). - Interface("year", dlCtx.year). - Msg("[DOWNLOAD] searching indexers") - - searchResults, err := dlCtx.indexerService.Search(ctx, criteria, nil) - if err != nil { - errStr := "indexer search failed: " + err.Error() - log.Error().Err(err).Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] indexer search failed") - return downloadResult{ - status: DownloadStatusFailed, - err: &errStr, - } - } - - log.Info(). - Int("results", len(searchResults)). - Str("artist", dlCtx.artistName). - Str("album", dlCtx.albumTitle). - Msg("[DOWNLOAD] search completed") - - if len(searchResults) == 0 { - log.Warn().Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] no results found") - return downloadResult{status: DownloadStatusNoResults} - } - - best := selectBestResult(searchResults) - - seeders := 0 - if best.Seeders != nil { - seeders = *best.Seeders - } - log.Info(). - Str("title", best.Title). - Str("indexer", best.Indexer). - Int("seeders", seeders). - Uint64("size_bytes", best.Size). - Interface("infohash", best.Infohash). - Msg("[DOWNLOAD] selected best result") - - log.Info().Str("url", best.DownloadURL).Msg("[DOWNLOAD] fetching torrent file") - - torrent, err := fetchTorrentFile(ctx, best.DownloadURL) - if err != nil { - errStr := "failed to fetch torrent file: " + err.Error() - log.Error().Err(err).Str("url", best.DownloadURL).Msg("[DOWNLOAD] failed to fetch torrent file") - return downloadResult{ - status: DownloadStatusFailed, - indexer: &best.Indexer, - err: &errStr, - } - } - - log.Info().Int("size_bytes", len(torrent.Data)).Str("infohash", torrent.InfoHash).Msg("[DOWNLOAD] adding torrent file to client") - - if err := dlCtx.torrentService.AddTorrentFile(ctx, torrent.Data, nil); err != nil { - errStr := "failed to add torrent: " + err.Error() - log.Error().Err(err).Msg("[DOWNLOAD] failed to add torrent") - return downloadResult{ - status: DownloadStatusFailed, - indexer: &best.Indexer, - err: &errStr, - } - } - - log.Info().Str("indexer", best.Indexer).Str("hash", torrent.InfoHash).Msg("[DOWNLOAD] torrent added successfully") - - infoHash := torrent.InfoHash - - var queueIDStr *string - if dlCtx.db != nil { - title := dlCtx.artistName + " - " + dlCtx.albumTitle - size := int64(best.Size) - queueID, err := dlCtx.db.AddToDownloadQueue(ctx, title, size, &infoHash, &best.Indexer, dlCtx.albumID, dlCtx.artistID) - if err != nil { - log.Warn().Err(err).Str("title", title).Msg("[DOWNLOAD] failed to add to download queue") - } else { - idStr := queueID.String() - queueIDStr = &idStr - log.Info().Str("queue_id", idStr).Str("title", title).Str("hash", infoHash).Msg("[DOWNLOAD] added to download queue") - } - } - - return downloadResult{ - status: DownloadStatusAdded, - torrentHash: &infoHash, - indexer: &best.Indexer, - queueID: queueIDStr, - } -} - -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 -} - -type torrentFile struct { - Data []byte - InfoHash string -} - -// fetchTorrentFile downloads a .torrent file from the given URL and extracts infohash. -// This is necessary because the torrent client may be on a different network -// (e.g., behind VPN) and cannot access the indexer directly. -func fetchTorrentFile(ctx context.Context, url string) (*torrentFile, error) { - client := &http.Client{Timeout: 30 * time.Second} - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("fetch torrent: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - mi, err := metainfo.Load(bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("parse torrent: %w", err) - } - - hash := mi.HashInfoBytes().HexString() - - return &torrentFile{Data: data, InfoHash: hash}, nil -} - -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 RefreshResult struct { - ArtistID string `json:"artist_id"` - ArtistName string `json:"artist_name"` - AlbumsUpdated int `json:"albums_updated"` - AlbumsAdded int `json:"albums_added"` -} - -func RefreshArtist( - ctx context.Context, - foreignArtistID string, - metadataClient *metadata.Client, - db *database.DB, -) (*RefreshResult, error) { - if db == nil { - return nil, &NotFoundError{Message: "database not available"} - } - - existingArtist, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID) - if err != nil { - return nil, &NotFoundError{Message: "artist not found: " + foreignArtistID} - } - - artist, err := metadataClient.GetArtist(ctx, foreignArtistID) - if err != nil { - return nil, err - } - - 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, - }) - } - - artistMetadataID, err := db.UpsertArtistMetadata(ctx, dbArtist) - if err != nil { - return nil, err - } - - existingAlbumCount, _ := db.CountAlbumsByArtist(ctx, existingArtist.ID) - - albumsResponse, err := metadataClient.GetArtistAlbums(ctx, foreignArtistID, 500, 0) - if err != nil { - return nil, err - } - - var albumsUpdated int - for _, album := range albumsResponse.Albums { - 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}) - } - - if _, err := db.UpsertAlbum(ctx, dbAlbum, artistMetadataID); err != nil { - log.Warn().Err(err).Str("album", album.Title).Msg("failed to upsert album during refresh") - } else { - albumsUpdated++ - } - } - - newAlbumCount, _ := db.CountAlbumsByArtist(ctx, artistMetadataID) - albumsAdded := int(newAlbumCount - existingAlbumCount) - if albumsAdded < 0 { - albumsAdded = 0 - } - - return &RefreshResult{ - ArtistID: foreignArtistID, - ArtistName: artist.Name, - AlbumsUpdated: albumsUpdated, - AlbumsAdded: albumsAdded, - }, nil -} - -type NotFoundError struct { - Message string -} - -func (e *NotFoundError) Error() string { - return e.Message -} diff --git a/internal/services/import.go b/internal/services/import.go deleted file mode 100644 index 3b6623c..0000000 --- a/internal/services/import.go +++ /dev/null @@ -1,234 +0,0 @@ -package services - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/fujin/music-agregator/internal/database" - "github.com/google/uuid" - "github.com/rs/zerolog/log" -) - -type ImportResult struct { - QueueID string `json:"queue_id"` - ArtistName string `json:"artist_name"` - AlbumTitle string `json:"album_title"` - TargetPath string `json:"target_path"` - FilesCopied int `json:"files_copied"` - TotalSize int64 `json:"total_size"` - Files []string `json:"files"` -} - -func ImportCompletedDownload( - ctx context.Context, - queueID uuid.UUID, - basePath string, - db *database.DB, - torrentService *TorrentService, -) (*ImportResult, error) { - log.Info().Str("queue_id", queueID.String()).Str("base_path", basePath).Msg("[IMPORT] starting import") - - item, err := db.GetDownloadQueueItem(ctx, queueID) - if err != nil { - log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[IMPORT] queue item not found") - return nil, fmt.Errorf("queue item not found: %w", err) - } - - log.Info().Str("title", item.Title).Str("status", item.Status).Msg("[IMPORT] found queue item") - - if item.Status != "completed" && item.Status != "seeding" { - log.Error().Str("status", item.Status).Msg("[IMPORT] download not completed") - return nil, fmt.Errorf("download not completed, status: %s", item.Status) - } - - if item.TorrentHash == nil { - log.Error().Msg("[IMPORT] no torrent hash for queue item") - return nil, fmt.Errorf("no torrent hash for queue item") - } - - log.Info().Str("hash", *item.TorrentHash).Msg("[IMPORT] fetching torrent info") - torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash) - if err != nil { - log.Error().Err(err).Str("hash", *item.TorrentHash).Msg("[IMPORT] torrent not found") - return nil, fmt.Errorf("torrent not found: %w", err) - } - - log.Info().Str("name", torrent.Name).Str("save_path", torrent.SavePath).Msg("[IMPORT] torrent info retrieved") - - var artistName, albumTitle string - if item.AlbumID != nil { - album, err := db.GetAlbumDetailByID(ctx, *item.AlbumID) - if err == nil { - artistName = album.ArtistName - albumTitle = album.Title - log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] resolved from database") - } - } - - if artistName == "" || albumTitle == "" { - parts := strings.SplitN(item.Title, " - ", 2) - if len(parts) == 2 { - artistName = parts[0] - albumTitle = parts[1] - } else { - artistName = "Unknown Artist" - albumTitle = item.Title - } - log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] parsed from title") - } - - artistName = sanitizePath(artistName) - albumTitle = sanitizePath(albumTitle) - - targetDir := filepath.Join(basePath, artistName, albumTitle) - log.Info().Str("target_dir", targetDir).Msg("[IMPORT] creating target directory") - if err := os.MkdirAll(targetDir, 0755); err != nil { - log.Error().Err(err).Str("target_dir", targetDir).Msg("[IMPORT] failed to create target directory") - return nil, fmt.Errorf("failed to create target directory: %w", err) - } - - sourcePath := filepath.Join(torrent.SavePath, torrent.Name) - log.Info().Str("source_path", sourcePath).Msg("[IMPORT] checking source path") - - var filesCopied int - var totalSize int64 - var copiedFiles []string - - sourceInfo, err := os.Stat(sourcePath) - if err != nil { - log.Error().Err(err).Str("source_path", sourcePath).Msg("[IMPORT] source path not found") - return nil, fmt.Errorf("source path not found: %w", err) - } - - if sourceInfo.IsDir() { - log.Info().Str("source_path", sourcePath).Msg("[IMPORT] source is directory, walking files") - err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - - if !isAudioFile(info.Name()) { - log.Debug().Str("file", info.Name()).Msg("[IMPORT] skipping non-audio file") - return nil - } - - relPath, _ := filepath.Rel(sourcePath, path) - targetPath := filepath.Join(targetDir, relPath) - - if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { - return err - } - - log.Info().Str("src", path).Str("dst", targetPath).Msg("[IMPORT] copying file") - if err := copyFile(path, targetPath); err != nil { - log.Warn().Err(err).Str("file", path).Msg("[IMPORT] failed to copy file") - return nil - } - - filesCopied++ - totalSize += info.Size() - copiedFiles = append(copiedFiles, relPath) - return nil - }) - if err != nil { - log.Error().Err(err).Msg("[IMPORT] failed to copy files") - return nil, fmt.Errorf("failed to copy files: %w", err) - } - } else { - if isAudioFile(sourceInfo.Name()) { - targetPath := filepath.Join(targetDir, sourceInfo.Name()) - log.Info().Str("src", sourcePath).Str("dst", targetPath).Msg("[IMPORT] copying single file") - if err := copyFile(sourcePath, targetPath); err != nil { - log.Error().Err(err).Msg("[IMPORT] failed to copy file") - return nil, fmt.Errorf("failed to copy file: %w", err) - } - filesCopied = 1 - totalSize = sourceInfo.Size() - copiedFiles = append(copiedFiles, sourceInfo.Name()) - } - } - - log.Info().Int("files_copied", filesCopied).Int64("total_size", totalSize).Msg("[IMPORT] file copy completed") - - log.Info().Msg("[IMPORT] updating queue status to imported") - if err := db.UpdateDownloadQueueStatus(ctx, queueID, "imported", nil); err != nil { - log.Warn().Err(err).Msg("[IMPORT] failed to update queue status to imported") - } - - if item.AlbumID != nil { - log.Info().Msg("[IMPORT] removing from wanted albums") - db.RemoveFromWantedAlbums(ctx, *item.AlbumID) - } - - log.Info(). - Str("artist", artistName). - Str("album", albumTitle). - Str("target_path", targetDir). - Int("files_copied", filesCopied). - Msg("[IMPORT] import completed successfully") - - return &ImportResult{ - QueueID: queueID.String(), - ArtistName: artistName, - AlbumTitle: albumTitle, - TargetPath: targetDir, - FilesCopied: filesCopied, - TotalSize: totalSize, - Files: copiedFiles, - }, nil -} - -var pathSanitizeRegex = regexp.MustCompile(`[<>:"/\\|?*]`) - -func sanitizePath(s string) string { - s = pathSanitizeRegex.ReplaceAllString(s, "_") - s = strings.TrimSpace(s) - if s == "" { - s = "Unknown" - } - return s -} - -func isAudioFile(name string) bool { - ext := strings.ToLower(filepath.Ext(name)) - audioExts := map[string]bool{ - ".flac": true, - ".mp3": true, - ".m4a": true, - ".aac": true, - ".ogg": true, - ".opus": true, - ".wav": true, - ".wma": true, - ".alac": true, - } - return audioExts[ext] -} - -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - if _, err := io.Copy(destFile, sourceFile); err != nil { - return err - } - - return destFile.Sync() -} diff --git a/internal/services/indexer.go b/internal/services/indexer.go deleted file mode 100644 index 3763c1f..0000000 --- a/internal/services/indexer.go +++ /dev/null @@ -1,122 +0,0 @@ -package services - -import ( - "context" - "fmt" - "strings" - - "github.com/fujin/music-agregator/internal/config" - "github.com/fujin/music-agregator/internal/indexer" - "github.com/rs/zerolog/log" -) - -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) - } - - svc := &IndexerService{indexers: indexers} - svc.checkHealth(context.Background()) - - return svc, nil -} - -func (s *IndexerService) checkHealth(ctx context.Context) { - for _, idx := range s.indexers { - if err := idx.TestConnection(ctx); err != nil { - log.Warn(). - Str("indexer", idx.Name()). - Err(err). - Msg("[INDEXER] failed to connect to indexer") - } else { - log.Info(). - Str("indexer", idx.Name()). - Msg("[INDEXER] connected successfully") - } - } -} - -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 - - log.Info(). - Str("artist", criteria.Artist). - Interface("album", criteria.Album). - Interface("year", criteria.Year). - Msg("[INDEXER] searching indexers") - - for _, idx := range s.indexers { - if indexerName != nil && idx.Name() != *indexerName { - continue - } - - log.Debug().Str("indexer", idx.Name()).Msg("[INDEXER] querying indexer") - - r, err := idx.Search(ctx, criteria) - if err != nil { - log.Warn(). - Str("indexer", idx.Name()). - Err(err). - Msg("[INDEXER] search failed") - continue - } - - log.Info(). - Str("indexer", idx.Name()). - Int("results", len(r)). - Msg("[INDEXER] search completed") - - results = append(results, r...) - } - - log.Info().Int("total_results", len(results)).Msg("[INDEXER] search finished") - 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 -} diff --git a/internal/services/queue.go b/internal/services/queue.go deleted file mode 100644 index 562e863..0000000 --- a/internal/services/queue.go +++ /dev/null @@ -1,288 +0,0 @@ -package services - -import ( - "context" - "strings" - - "github.com/fujin/music-agregator/internal/database" - "github.com/fujin/music-agregator/internal/torrent" - "github.com/google/uuid" - "github.com/rs/zerolog/log" -) - -type QueueSyncResult struct { - Synced int `json:"synced"` - Updated int `json:"updated"` -} - -func SyncDownloadQueue(ctx context.Context, db *database.DB, torrentService *TorrentService) (*QueueSyncResult, error) { - log.Info().Msg("[QUEUE_SYNC] starting queue sync") - - if !torrentService.IsConfigured() { - log.Warn().Msg("[QUEUE_SYNC] torrent service not configured, skipping") - return &QueueSyncResult{}, nil - } - - torrents, err := torrentService.ListTorrents(ctx) - if err != nil { - log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list torrents") - return nil, err - } - - log.Info().Int("torrent_count", len(torrents)).Msg("[QUEUE_SYNC] fetched torrents from client") - - torrentMap := make(map[string]torrent.TorrentInfo) - torrentByName := make(map[string]torrent.TorrentInfo) - for _, t := range torrents { - torrentMap[t.Hash] = t - nameLower := strings.ToLower(t.Name) - torrentByName[nameLower] = t - log.Debug(). - Str("hash", t.Hash). - Str("name", t.Name). - Str("state", string(t.State)). - Float64("progress", t.Progress). - Msg("[QUEUE_SYNC] torrent info") - } - - queueItems, err := db.ListDownloadQueue(ctx, nil) - if err != nil { - log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list queue items") - return nil, err - } - - log.Info().Int("queue_count", len(queueItems)).Msg("[QUEUE_SYNC] fetched queue items from database") - - var synced, updated int - for _, item := range queueItems { - var t torrent.TorrentInfo - var exists bool - - if item.TorrentHash != nil { - t, exists = torrentMap[*item.TorrentHash] - if !exists { - log.Debug().Str("hash", *item.TorrentHash).Str("title", item.Title).Msg("[QUEUE_SYNC] torrent not found by hash") - } - } - - if !exists { - titleLower := strings.ToLower(item.Title) - for name, torr := range torrentByName { - if strings.Contains(name, titleLower) || strings.Contains(titleLower, name) { - t = torr - exists = true - hash := t.Hash - if item.TorrentHash == nil { - log.Info().Str("title", item.Title).Str("matched_name", t.Name).Str("hash", hash).Msg("[QUEUE_SYNC] matched by title, updating hash") - if err := db.UpdateDownloadQueueHash(ctx, item.ID, hash); err != nil { - log.Error().Err(err).Msg("[QUEUE_SYNC] failed to update hash") - } - } - break - } - } - } - - if !exists { - log.Debug().Str("title", item.Title).Msg("[QUEUE_SYNC] no matching torrent found") - continue - } - - synced++ - - newStatus := mapTorrentState(t.State) - sizeLeft := int64(float64(item.Size) * (1 - t.Progress)) - - if newStatus != item.Status || item.Progress != float32(t.Progress) { - log.Info(). - Str("title", item.Title). - Str("old_status", item.Status). - Str("new_status", newStatus). - Float32("old_progress", item.Progress). - Float64("new_progress", t.Progress). - Msg("[QUEUE_SYNC] updating queue item") - - if err := db.UpdateDownloadQueueProgress(ctx, item.ID, float32(t.Progress), sizeLeft, newStatus); err != nil { - log.Error().Err(err).Str("title", item.Title).Msg("[QUEUE_SYNC] failed to update queue item") - continue - } - updated++ - - if newStatus == "completed" && item.AlbumID != nil { - log.Info().Str("title", item.Title).Msg("[QUEUE_SYNC] download completed, removing from wanted albums") - db.RemoveFromWantedAlbums(ctx, *item.AlbumID) - } - } - } - - log.Info().Int("synced", synced).Int("updated", updated).Msg("[QUEUE_SYNC] sync completed") - return &QueueSyncResult{Synced: synced, Updated: updated}, nil -} - -func mapTorrentState(state torrent.TorrentState) string { - switch state { - case torrent.StateDownloading: - return "downloading" - case torrent.StateSeeding: - return "completed" - case torrent.StatePaused: - return "paused" - case torrent.StateQueued: - return "queued" - case torrent.StateChecking: - return "checking" - case torrent.StateError: - return "failed" - default: - return "queued" - } -} - -func HandleFailedDownload(ctx context.Context, db *database.DB, queueID uuid.UUID, errorMessage string) error { - log.Info().Str("queue_id", queueID.String()).Str("error", errorMessage).Msg("[FAILED_DOWNLOAD] handling failed download") - - item, err := db.GetDownloadQueueItem(ctx, queueID) - if err != nil { - log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[FAILED_DOWNLOAD] failed to get queue item") - return err - } - - log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] marking as failed") - if err := db.UpdateDownloadQueueStatus(ctx, queueID, "failed", &errorMessage); err != nil { - log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to update status") - return err - } - - if item.ArtistID != nil && item.AlbumID != nil { - log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] adding to blocklist") - if err := db.AddToBlocklist(ctx, *item.ArtistID, *item.AlbumID, item.Title, item.TorrentHash, item.Indexer); err != nil { - log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to blocklist") - return err - } - } - - if item.AlbumID != nil { - log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] re-adding to wanted albums for retry") - if err := db.AddToWantedAlbums(ctx, *item.AlbumID); err != nil { - log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to wanted albums") - return err - } - } - - log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] handling complete") - return nil -} - -type BlocklistResult struct { - Blocklisted bool `json:"blocklisted"` - Removed bool `json:"removed"` -} - -type JobStatus struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Progress float32 `json:"progress"` - Size int64 `json:"size"` - SizeLeft int64 `json:"size_left"` - TorrentHash *string `json:"torrent_hash,omitempty"` - Indexer *string `json:"indexer,omitempty"` - ErrorMessage *string `json:"error_message,omitempty"` - CreatedAt string `json:"created_at"` - CompletedAt *string `json:"completed_at,omitempty"` -} - -func GetJobStatus(ctx context.Context, db *database.DB, torrentService *TorrentService, jobID uuid.UUID) (*JobStatus, error) { - log.Info().Str("job_id", jobID.String()).Msg("[JOB_STATUS] fetching job status") - - item, err := db.GetDownloadQueueItem(ctx, jobID) - if err != nil { - log.Error().Err(err).Str("job_id", jobID.String()).Msg("[JOB_STATUS] job not found") - return nil, err - } - - status := &JobStatus{ - ID: item.ID.String(), - Title: item.Title, - Status: item.Status, - Progress: item.Progress, - Size: item.Size, - SizeLeft: item.SizeLeft, - TorrentHash: item.TorrentHash, - Indexer: item.Indexer, - ErrorMessage: item.ErrorMessage, - CreatedAt: item.AddedAt.Format("2006-01-02T15:04:05Z07:00"), - } - - if item.CompletedAt != nil { - completedStr := item.CompletedAt.Format("2006-01-02T15:04:05Z07:00") - status.CompletedAt = &completedStr - } - - if (item.Status == "downloading" || item.Status == "queued") && item.TorrentHash != nil && torrentService.IsConfigured() { - log.Debug().Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] fetching torrent progress") - torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash) - if err == nil { - status.Progress = float32(torrent.Progress) - status.SizeLeft = int64(float64(item.Size) * (1 - torrent.Progress)) - status.Status = mapTorrentState(torrent.State) - log.Info(). - Str("status", status.Status). - Float32("progress", status.Progress). - Msg("[JOB_STATUS] updated from torrent client") - } else { - log.Warn().Err(err).Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] failed to get torrent info") - } - } - - log.Info().Str("status", status.Status).Float32("progress", status.Progress).Msg("[JOB_STATUS] returning status") - return status, nil -} - -func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *TorrentService, queueID uuid.UUID) (*BlocklistResult, error) { - log.Info().Str("queue_id", queueID.String()).Msg("[BLOCKLIST] starting blocklist and remove") - - item, err := db.GetDownloadQueueItem(ctx, queueID) - if err != nil { - log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[BLOCKLIST] failed to get queue item") - return nil, err - } - - log.Info().Str("title", item.Title).Interface("torrent_hash", item.TorrentHash).Msg("[BLOCKLIST] processing item") - - result := &BlocklistResult{} - - if item.ArtistID != nil { - albumID := item.AlbumID - if albumID == nil { - albumID = &uuid.Nil - } - log.Info().Str("title", item.Title).Msg("[BLOCKLIST] adding to blocklist") - if err := db.AddToBlocklist(ctx, *item.ArtistID, *albumID, item.Title, item.TorrentHash, item.Indexer); err == nil { - result.Blocklisted = true - log.Info().Str("title", item.Title).Msg("[BLOCKLIST] added to blocklist") - } else { - log.Warn().Err(err).Str("title", item.Title).Msg("[BLOCKLIST] failed to add to blocklist") - } - } - - if item.TorrentHash != nil && torrentService.IsConfigured() { - log.Info().Str("hash", *item.TorrentHash).Msg("[BLOCKLIST] removing torrent from client") - torrentService.RemoveTorrent(ctx, *item.TorrentHash, true) - } - - log.Info().Str("title", item.Title).Msg("[BLOCKLIST] deleting from queue") - if err := db.DeleteDownloadQueueItem(ctx, queueID); err != nil { - log.Error().Err(err).Msg("[BLOCKLIST] failed to delete queue item") - return nil, err - } - result.Removed = true - - if item.AlbumID != nil { - log.Info().Str("title", item.Title).Msg("[BLOCKLIST] re-adding album to wanted list") - db.AddToWantedAlbums(ctx, *item.AlbumID) - } - - log.Info().Bool("blocklisted", result.Blocklisted).Bool("removed", result.Removed).Msg("[BLOCKLIST] completed") - return result, nil -} diff --git a/internal/services/torrent.go b/internal/services/torrent.go deleted file mode 100644 index ffb89a7..0000000 --- a/internal/services/torrent.go +++ /dev/null @@ -1,105 +0,0 @@ -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 -} - -func (s *TorrentService) GetStubClient() *torrent.StubClient { - if stub, ok := s.client.(*torrent.StubClient); ok { - return stub - } - return nil -} diff --git a/internal/torrent/client.go b/internal/torrent/client.go deleted file mode 100644 index 3516a08..0000000 --- a/internal/torrent/client.go +++ /dev/null @@ -1,49 +0,0 @@ -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 -} diff --git a/internal/torrent/qbittorrent.go b/internal/torrent/qbittorrent.go deleted file mode 100644 index fb771cc..0000000 --- a/internal/torrent/qbittorrent.go +++ /dev/null @@ -1,365 +0,0 @@ -package torrent - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/cookiejar" - "net/url" - "strings" - "sync" - - "github.com/rs/zerolog/log" -) - -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", "stalledDL": - 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 - } - - log.Debug().Str("url", torrentURL).Msg("[QBITTORRENT] adding torrent URL") - - 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 { - log.Error().Err(err).Msg("[QBITTORRENT] request failed") - return err - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - bodyStr := strings.TrimSpace(string(body)) - - log.Debug().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent response") - - if !statusOK(resp.StatusCode) { - log.Error().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent failed") - return fmt.Errorf("%w: %s", ErrInvalidRequest, bodyStr) - } - - if bodyStr == "Fails." { - log.Error().Str("url", torrentURL).Msg("[QBITTORRENT] torrent add rejected") - return fmt.Errorf("qBittorrent rejected torrent: %s", torrentURL) - } - - log.Info().Msg("[QBITTORRENT] torrent added successfully") - 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 -} diff --git a/internal/torrent/stub.go b/internal/torrent/stub.go deleted file mode 100644 index 5750c16..0000000 --- a/internal/torrent/stub.go +++ /dev/null @@ -1,227 +0,0 @@ -package torrent - -import ( - "context" - "crypto/sha1" - "encoding/hex" - "fmt" - "os" - "sync" - "time" -) - -type StubClient struct { - logPath string - savePath string - mu sync.RWMutex - logMu sync.Mutex - torrents map[string]*TorrentInfo -} - -func NewStubClient(logPath, savePath string) *StubClient { - return &StubClient{ - logPath: logPath, - savePath: savePath, - torrents: make(map[string]*TorrentInfo), - } -} - -func (c *StubClient) log(format string, args ...any) { - if c.logPath == "" { - return - } - c.logMu.Lock() - defer c.logMu.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.mu.RLock() - defer c.mu.RUnlock() - - c.log("LIST_TORRENTS count=%d", len(c.torrents)) - - result := make([]TorrentInfo, 0, len(c.torrents)) - for _, t := range c.torrents { - result = append(result, *t) - } - return result, nil -} - -func (c *StubClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) { - c.mu.RLock() - defer c.mu.RUnlock() - - c.log("GET_TORRENT hash=%s", hash) - - t, ok := c.torrents[hash] - if !ok { - return nil, ErrTorrentNotFound - } - return t, nil -} - -func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *string) error { - path := c.savePath - if savePath != nil { - path = *savePath - } - - hash := generateHashFromURL(url) - name := "Torrent-" + hash[:8] - - c.mu.Lock() - c.torrents[hash] = &TorrentInfo{ - Hash: hash, - Name: name, - Size: 500 * 1024 * 1024, - Progress: 0, - DownloadSpeed: 0, - UploadSpeed: 0, - State: StateQueued, - SavePath: path, - } - c.mu.Unlock() - - c.log("ADD_TORRENT_URL url=%s hash=%s save_path=%s", url, hash, path) - return nil -} - -func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error { - path := c.savePath - if savePath != nil { - path = *savePath - } - - hash := generateHashFromData(data) - name := "Torrent-" + hash[:8] - - c.mu.Lock() - c.torrents[hash] = &TorrentInfo{ - Hash: hash, - Name: name, - Size: uint64(len(data) * 100), - Progress: 0, - DownloadSpeed: 0, - UploadSpeed: 0, - State: StateQueued, - SavePath: path, - } - c.mu.Unlock() - - c.log("ADD_TORRENT_FILE size=%d hash=%s save_path=%s", len(data), hash, path) - return nil -} - -func (c *StubClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error { - c.mu.Lock() - delete(c.torrents, hash) - c.mu.Unlock() - - c.log("REMOVE_TORRENT hash=%s delete_files=%t", hash, deleteFiles) - return nil -} - -func (c *StubClient) PauseTorrent(ctx context.Context, hash string) error { - c.mu.Lock() - if t, ok := c.torrents[hash]; ok { - t.State = StatePaused - t.DownloadSpeed = 0 - } - c.mu.Unlock() - - c.log("PAUSE_TORRENT hash=%s", hash) - return nil -} - -func (c *StubClient) ResumeTorrent(ctx context.Context, hash string) error { - c.mu.Lock() - if t, ok := c.torrents[hash]; ok { - if t.Progress < 1.0 { - t.State = StateDownloading - } else { - t.State = StateSeeding - } - } - c.mu.Unlock() - - c.log("RESUME_TORRENT hash=%s", hash) - return nil -} - -func (c *StubClient) SetTorrentState(hash string, state TorrentState, progress float64) { - c.mu.Lock() - defer c.mu.Unlock() - - if t, ok := c.torrents[hash]; ok { - t.State = state - t.Progress = progress - if state == StateSeeding { - t.Progress = 1.0 - } - } -} - -func (c *StubClient) SetTorrentName(hash, name string) { - c.mu.Lock() - defer c.mu.Unlock() - - if t, ok := c.torrents[hash]; ok { - t.Name = name - } -} - -func (c *StubClient) AddTorrentDirect(info TorrentInfo) { - c.mu.Lock() - defer c.mu.Unlock() - c.torrents[info.Hash] = &info -} - -func (c *StubClient) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - c.torrents = make(map[string]*TorrentInfo) -} - -func (c *StubClient) GetAllTorrents() map[string]*TorrentInfo { - c.mu.RLock() - defer c.mu.RUnlock() - - result := make(map[string]*TorrentInfo, len(c.torrents)) - for k, v := range c.torrents { - copy := *v - result[k] = © - } - return result -} - -func generateHashFromURL(url string) string { - h := sha1.New() - h.Write([]byte(url)) - return hex.EncodeToString(h.Sum(nil)) -} - -func generateHashFromData(data []byte) string { - h := sha1.New() - h.Write(data) - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/proto/metadata/v1/metadata.proto b/proto/metadata/v1/metadata.proto deleted file mode 100644 index 51abbb1..0000000 --- a/proto/metadata/v1/metadata.proto +++ /dev/null @@ -1,186 +0,0 @@ -syntax = "proto3"; - -package metadata.v1; - -option go_package = "github.com/fujin/music-agregator/pkg/metadatapb/metadata/v1;metadatav1"; - -enum Provider { - PROVIDER_UNSPECIFIED = 0; - PROVIDER_MUSICBRAINZ = 1; -} - -// MetadataService provides music metadata aggregation. -service MetadataService { - // GetArtist retrieves an artist by ID or external source ID. - rpc GetArtist(GetArtistRequest) returns (Artist); - - // SearchArtists searches for artists by name. - rpc SearchArtists(SearchArtistsRequest) returns (SearchArtistsResponse); - - // GetAlbum retrieves an album by ID. - rpc GetAlbum(GetAlbumRequest) returns (Album); - - // GetArtistAlbums retrieves all albums by an artist. - rpc GetArtistAlbums(GetArtistAlbumsRequest) returns (GetArtistAlbumsResponse); - - // GetTrack retrieves a track by ID. - rpc GetTrack(GetTrackRequest) returns (Track); - - // GetAlbumTracks retrieves all tracks on an album. - rpc GetAlbumTracks(GetAlbumTracksRequest) returns (GetAlbumTracksResponse); - - // SyncArtist triggers ingestion of an artist from external sources. - rpc SyncArtist(SyncArtistRequest) returns (SyncArtistResponse); -} - -// Requests - -message GetArtistRequest { - oneof identifier { - string id = 1; // Internal UUID - ExternalID external = 2; // External source ID (e.g., musicbrainz MBID) - } - Provider provider = 3; // UNSPECIFIED = query all providers -} - -message SearchArtistsRequest { - string query = 1; - int32 limit = 2; - int32 offset = 3; - Provider provider = 4; -} - -message GetAlbumRequest { - oneof identifier { - string id = 1; - ExternalID external = 2; - } - Provider provider = 3; -} - -message GetArtistAlbumsRequest { - string artist_id = 1; - int32 limit = 2; - int32 offset = 3; - Provider provider = 4; -} - -message GetTrackRequest { - oneof identifier { - string id = 1; - ExternalID external = 2; - string isrc = 3; - } - Provider provider = 4; -} - -message GetAlbumTracksRequest { - string album_id = 1; - Provider provider = 2; -} - -message SyncArtistRequest { - oneof target { - string name = 1; - ExternalID external = 2; - } - Provider provider = 3; -} - -// Responses - -message SearchArtistsResponse { - repeated Artist artists = 1; - int32 total = 2; -} - -message GetArtistAlbumsResponse { - repeated Album albums = 1; - int32 total = 2; -} - -message GetAlbumTracksResponse { - repeated Track tracks = 1; -} - -message SyncArtistResponse { - Artist artist = 1; - int32 albums_synced = 2; - int32 tracks_synced = 3; -} - -// Core Entities - -message Artist { - string id = 1; - string name = 2; - string sort_name = 3; - string artist_type = 4; // person, group, orchestra, etc. - string country = 5; - string formed_date = 6; - string disbanded_date = 7; - string description = 8; - string image_url = 9; - repeated Genre genres = 10; - repeated ExternalID external_ids = 11; -} - -message Album { - string id = 1; - string title = 2; - string album_type = 3; // album, ep, single, compilation - string release_date = 4; - string upc = 5; - int32 total_tracks = 6; - int32 total_discs = 7; - string cover_url = 8; - repeated ArtistCredit artists = 9; - Label label = 10; - repeated Genre genres = 11; - repeated ExternalID external_ids = 12; -} - -message Track { - string id = 1; - string title = 2; - int32 duration_ms = 3; - string isrc = 4; - bool explicit = 5; - int32 disc_number = 6; - int32 track_number = 7; - repeated ArtistCredit artists = 8; - Work work = 9; - repeated ExternalID external_ids = 10; -} - -message Work { - string id = 1; - string title = 2; - string work_type = 3; - string language = 4; - repeated ArtistCredit composers = 5; -} - -message Label { - string id = 1; - string name = 2; - string country = 3; -} - -message Genre { - string id = 1; - string name = 2; -} - -message ArtistCredit { - Artist artist = 1; - string role = 2; // primary, featured, remixer, producer - int32 position = 3; - string join_phrase = 4; // " & ", " feat. ", etc. -} - -message ExternalID { - string source = 1; // musicbrainz, spotify, discogs, etc. - string source_id = 2; - string url = 3; -}