Start from the beginning
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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, "<error") && strings.Contains(xmlStr, `code="1`) {
|
||||
return ErrAuthFailed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type rssResponse struct {
|
||||
Channel struct {
|
||||
Items []rssItem `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
Error *rssError `xml:"error"`
|
||||
}
|
||||
|
||||
type rssError struct {
|
||||
Code string `xml:"code,attr"`
|
||||
Description string `xml:"description,attr"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
GUID string `xml:"guid"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Comments string `xml:"comments"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Enclosure enclosure `xml:"enclosure"`
|
||||
Attrs []attr `xml:"attr"`
|
||||
}
|
||||
|
||||
type enclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length string `xml:"length,attr"`
|
||||
}
|
||||
|
||||
type attr struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:"value,attr"`
|
||||
}
|
||||
|
||||
func (i *TorznabIndexer) parseResponse(data []byte) ([]SearchResult, error) {
|
||||
var rss rssResponse
|
||||
if err := xml.Unmarshal(data, &rss); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrParseError, err)
|
||||
}
|
||||
|
||||
if rss.Error != nil {
|
||||
if strings.HasPrefix(rss.Error.Code, "1") {
|
||||
return nil, ErrAuthFailed
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrSearchFailed, rss.Error.Description)
|
||||
}
|
||||
|
||||
var results []SearchResult
|
||||
for _, item := range rss.Channel.Items {
|
||||
result := i.parseItem(item)
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (i *TorznabIndexer) parseItem(item rssItem) SearchResult {
|
||||
attrs := make(map[string]string)
|
||||
var categories []uint32
|
||||
|
||||
for _, a := range item.Attrs {
|
||||
if a.Name == "category" {
|
||||
if v, err := strconv.ParseUint(a.Value, 10, 32); err == nil {
|
||||
categories = append(categories, uint32(v))
|
||||
}
|
||||
} else {
|
||||
attrs[a.Name] = a.Value
|
||||
}
|
||||
}
|
||||
|
||||
size := uint64(0)
|
||||
if s, ok := attrs["size"]; ok {
|
||||
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
|
||||
size = v
|
||||
}
|
||||
} else if item.Enclosure.Length != "" {
|
||||
if v, err := strconv.ParseUint(item.Enclosure.Length, 10, 64); err == nil {
|
||||
size = v
|
||||
}
|
||||
}
|
||||
|
||||
result := SearchResult{
|
||||
GUID: item.GUID,
|
||||
Title: item.Title,
|
||||
DownloadURL: item.Link,
|
||||
Size: size,
|
||||
Indexer: i.name,
|
||||
Categories: categories,
|
||||
}
|
||||
|
||||
if item.Comments != "" {
|
||||
result.InfoURL = &item.Comments
|
||||
}
|
||||
if item.PubDate != "" {
|
||||
result.PublishDate = &item.PubDate
|
||||
}
|
||||
if v, ok := attrs["artist"]; ok {
|
||||
result.Artist = &v
|
||||
}
|
||||
if v, ok := attrs["album"]; ok {
|
||||
result.Album = &v
|
||||
}
|
||||
if v, ok := attrs["year"]; ok {
|
||||
if y, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
y32 := uint32(y)
|
||||
result.Year = &y32
|
||||
}
|
||||
}
|
||||
if v, ok := attrs["label"]; ok {
|
||||
result.Label = &v
|
||||
}
|
||||
if v, ok := attrs["seeders"]; ok {
|
||||
if s, err := strconv.Atoi(v); err == nil {
|
||||
result.Seeders = &s
|
||||
}
|
||||
}
|
||||
if v, ok := attrs["leechers"]; ok {
|
||||
if l, err := strconv.Atoi(v); err == nil {
|
||||
result.Leechers = &l
|
||||
}
|
||||
}
|
||||
if v, ok := attrs["grabs"]; ok {
|
||||
if g, err := strconv.Atoi(v); err == nil {
|
||||
result.Grabs = &g
|
||||
}
|
||||
}
|
||||
if v, ok := attrs["infohash"]; ok {
|
||||
result.Infohash = &v
|
||||
}
|
||||
if v, ok := attrs["magneturl"]; ok {
|
||||
result.MagnetURL = &v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
pb "github.com/fujin/music-agregator/pkg/metadatapb/metadata/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
client pb.MetadataServiceClient
|
||||
}
|
||||
|
||||
func NewClient(endpoint string) (*Client, error) {
|
||||
endpoint = strings.TrimPrefix(endpoint, "http://")
|
||||
endpoint = strings.TrimPrefix(endpoint, "https://")
|
||||
|
||||
conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
client: pb.NewMetadataServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) SearchArtists(ctx context.Context, query string, limit, offset int32) (*pb.SearchArtistsResponse, error) {
|
||||
return c.client.SearchArtists(ctx, &pb.SearchArtistsRequest{
|
||||
Query: query,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) GetArtist(ctx context.Context, id string) (*pb.Artist, error) {
|
||||
return c.client.GetArtist(ctx, &pb.GetArtistRequest{
|
||||
Identifier: &pb.GetArtistRequest_Id{Id: id},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int32) (*pb.GetArtistAlbumsResponse, error) {
|
||||
return c.client.GetArtistAlbums(ctx, &pb.GetArtistAlbumsRequest{
|
||||
ArtistId: artistID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/database"
|
||||
"github.com/fujin/music-agregator/internal/indexer"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type RankedSearchResult struct {
|
||||
indexer.SearchResult
|
||||
Quality string `json:"quality"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type AlbumSearchResult struct {
|
||||
AlbumID string `json:"album_id"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
Results []RankedSearchResult `json:"results"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
func SearchAlbum(
|
||||
ctx context.Context,
|
||||
albumID uuid.UUID,
|
||||
db *database.DB,
|
||||
indexerService *IndexerService,
|
||||
) (*AlbumSearchResult, error) {
|
||||
log.Info().Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] starting search")
|
||||
|
||||
album, err := db.GetAlbumDetailByID(ctx, albumID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] album not found")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Str("artist", album.ArtistName).Str("album", album.Title).Msg("[ALBUM_SEARCH] searching for album")
|
||||
|
||||
var year *uint32
|
||||
if album.ReleaseDate != nil {
|
||||
y := uint32(album.ReleaseDate.Year())
|
||||
year = &y
|
||||
}
|
||||
|
||||
criteria := &indexer.MusicSearchCriteria{
|
||||
Artist: album.ArtistName,
|
||||
Album: &album.Title,
|
||||
Year: year,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
results, err := indexerService.Search(ctx, criteria, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[ALBUM_SEARCH] indexer search failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Int("raw_results", len(results)).Msg("[ALBUM_SEARCH] got raw results from indexers")
|
||||
|
||||
var rankedResults []RankedSearchResult
|
||||
var blockedCount int
|
||||
for _, r := range results {
|
||||
blocked, _ := db.IsBlocklisted(ctx, r.Title, r.Infohash)
|
||||
if blocked {
|
||||
blockedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
quality := detectQuality(r.Title)
|
||||
score := calculateScore(quality, r.Seeders)
|
||||
|
||||
rankedResults = append(rankedResults, RankedSearchResult{
|
||||
SearchResult: r,
|
||||
Quality: quality,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
|
||||
if blockedCount > 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user