diff --git a/cmd/server/main.go b/cmd/server/main.go index ded7bb4..6d65e35 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -76,10 +76,11 @@ func main() { } handlers := &api.Handlers{ - IndexerService: indexerService, - TorrentService: torrentService, - MetadataClient: metadataClient, - DB: db, + IndexerService: indexerService, + TorrentService: torrentService, + MetadataClient: metadataClient, + DB: db, + StorageBasePath: cfg.Storage.BasePath, } router := api.NewRouter(handlers) diff --git a/config.test.yaml b/config.test.yaml new file mode 100644 index 0000000..f34e796 --- /dev/null +++ b/config.test.yaml @@ -0,0 +1,22 @@ +app: + port: 3000 + +database: + url: "postgresql://music:music@localhost:5433/music_aggregator" + +metadata: + endpoint: "http://localhost:50051" + +indexers: + - name: "Jackett" + indexer_type: jackett + url: "http://localhost:9117" + api_key: "apmtthy50kiu47wth8i1we22k5sn8ut6" + +torrent: + client_type: stub + save_path: "/tmp/music-downloads" + log_path: "/tmp/torrent-stub.log" + +storage: + base_path: "/tmp/music-library-test" diff --git a/containers/docker-compose.yml b/containers/docker-compose.yml index c6308fd..8800e87 100644 --- a/containers/docker-compose.yml +++ b/containers/docker-compose.yml @@ -33,7 +33,51 @@ services: ports: - "9117:9117" + gluetun: + image: qmcgaw/gluetun:latest + container_name: music-aggregator-vpn + restart: unless-stopped + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + - VPN_SERVICE_PROVIDER=custom + - VPN_TYPE=wireguard + - TZ=Europe/Warsaw + volumes: + - ./wg.conf:/gluetun/wireguard/wg0.conf:ro + ports: + - "8080:8080" + - "6881:6881" + - "6881:6881/udp" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: music-aggregator-qbittorrent + restart: unless-stopped + network_mode: "service:gluetun" + depends_on: + gluetun: + condition: service_healthy + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Warsaw + - WEBUI_PORT=8080 + volumes: + - qbittorrent_config:/config + - downloads:/downloads + volumes: postgres_data: jackett_config: jackett_downloads: + qbittorrent_config: + downloads: diff --git a/go.mod b/go.mod index 9524cb2..2a12a98 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/fujin/music-agregator -go 1.23 +go 1.24.0 require ( github.com/go-chi/chi/v5 v5.1.0 @@ -8,23 +8,36 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.6.0 github.com/rs/zerolog v1.33.0 - google.golang.org/grpc v1.65.0 - google.golang.org/protobuf v1.34.2 + 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 - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.16.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + lukechampine.com/blake3 v1.1.6 // indirect ) diff --git a/go.sum b/go.sum index 9abac0a..5642428 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,119 @@ +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= @@ -20,8 +122,24 @@ 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= @@ -30,41 +148,194 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY= +github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b h1:04+jVzTs2XBnOZcPsLnmrTGqltqJbZQ1Ey26hjYdQQ0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= +lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 71e43b6..c1692f8 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -5,796 +5,27 @@ import ( "net/http" "github.com/fujin/music-agregator/internal/database" - "github.com/fujin/music-agregator/internal/indexer" "github.com/fujin/music-agregator/internal/metadata" "github.com/fujin/music-agregator/internal/services" - "github.com/go-chi/chi/v5" "github.com/google/uuid" ) type Handlers struct { - IndexerService *services.IndexerService - TorrentService *services.TorrentService - MetadataClient *metadata.Client - DB *database.DB + 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 (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) { - indexers := h.IndexerService.GetIndexers(r.Context()) - writeJSON(w, http.StatusOK, indexers) -} - -type searchRequest struct { - Artist string `json:"artist"` - Album *string `json:"album,omitempty"` - Year *uint32 `json:"year,omitempty"` - Limit int `json:"limit,omitempty"` - Offset int `json:"offset,omitempty"` -} - -func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) { - var req searchRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.Limit == 0 { - req.Limit = 20 - } - - criteria := &indexer.MusicSearchCriteria{ - Artist: req.Artist, - Album: req.Album, - Year: req.Year, - Limit: req.Limit, - Offset: req.Offset, - } - - results, err := h.IndexerService.Search(r.Context(), criteria, nil) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, results) -} - -func (h *Handlers) ListTorrents(w http.ResponseWriter, r *http.Request) { - torrents, err := h.TorrentService.ListTorrents(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, torrents) -} - -func (h *Handlers) GetTorrent(w http.ResponseWriter, r *http.Request) { - hash := chi.URLParam(r, "hash") - torrent, err := h.TorrentService.GetTorrent(r.Context(), hash) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) - return - } - writeJSON(w, http.StatusOK, torrent) -} - -type addTorrentRequest struct { - URL string `json:"url"` - SavePath *string `json:"save_path,omitempty"` -} - -func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) { - var req addTorrentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if err := h.TorrentService.AddTorrentURL(r.Context(), req.URL, req.SavePath); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]string{"status": "added"}) -} - -type removeTorrentRequest struct { - DeleteFiles bool `json:"delete_files"` -} - -func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) { - hash := chi.URLParam(r, "hash") - - var req removeTorrentRequest - json.NewDecoder(r.Body).Decode(&req) - - if err := h.TorrentService.RemoveTorrent(r.Context(), hash, req.DeleteFiles); err != nil { - writeError(w, http.StatusNotFound, err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) -} - -func (h *Handlers) PauseTorrent(w http.ResponseWriter, r *http.Request) { - hash := chi.URLParam(r, "hash") - if err := h.TorrentService.PauseTorrent(r.Context(), hash); err != nil { - writeError(w, http.StatusNotFound, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "paused"}) -} - -func (h *Handlers) ResumeTorrent(w http.ResponseWriter, r *http.Request) { - hash := chi.URLParam(r, "hash") - if err := h.TorrentService.ResumeTorrent(r.Context(), hash); err != nil { - writeError(w, http.StatusNotFound, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"}) -} - -func (h *Handlers) SearchArtists(w http.ResponseWriter, r *http.Request) { - var req struct { - Query string `json:"query"` - Limit int32 `json:"limit,omitempty"` - Offset int32 `json:"offset,omitempty"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.Limit == 0 { - req.Limit = 10 - } - - result, err := h.MetadataClient.SearchArtists(r.Context(), req.Query, req.Limit, req.Offset) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, result) -} - -func (h *Handlers) GetArtistAlbums(w http.ResponseWriter, r *http.Request) { - artistID := chi.URLParam(r, "id") - - result, err := h.MetadataClient.GetArtistAlbums(r.Context(), artistID, 500, 0) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, result) -} - -func (h *Handlers) Sync(w http.ResponseWriter, r *http.Request) { - var req struct { - Artist string `json:"artist"` - Album *string `json:"album,omitempty"` - Download *bool `json:"download,omitempty"` - Store *bool `json:"store,omitempty"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - download := true - if req.Download != nil { - download = *req.Download - } - store := true - if req.Store != nil { - store = *req.Store - } - - options := services.SyncOptions{ - Artist: req.Artist, - Album: req.Album, - Download: download, - Store: store, - } - - result, err := services.Sync(r.Context(), options, h.MetadataClient, h.IndexerService, h.TorrentService, h.DB) - if err != nil { - if _, ok := err.(*services.NotFoundError); ok { - writeError(w, http.StatusNotFound, err.Error()) - return - } - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, result) -} - -func (h *Handlers) ListLibraryArtists(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artists, err := h.DB.ListArtists(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, artists) -} - -func (h *Handlers) ListLibraryAlbums(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - albums, err := h.DB.ListAllAlbums(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, albums) -} - -func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusServiceUnavailable, "database not connected") - return - } - - artistCount, err := h.DB.CountArtists(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - albumCount, err := h.DB.CountAlbums(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]int64{ - "artists": artistCount, - "albums": albumCount, - }) -} - -func (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) 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) 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) 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) -} - -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) -} - -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, - }) -} - func parseUUID(s string) (uuid.UUID, error) { return uuid.Parse(s) } -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 writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/api/handlers_album.go b/internal/api/handlers_album.go new file mode 100644 index 0000000..77aa55a --- /dev/null +++ b/internal/api/handlers_album.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/fujin/music-agregator/internal/services" + "github.com/go-chi/chi/v5" +) + +func (h *Handlers) GetAlbum(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + albumIDStr := chi.URLParam(r, "id") + albumID, err := parseUUID(albumIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid album ID") + return + } + + album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID) + if err != nil { + writeError(w, http.StatusNotFound, "album not found") + return + } + + writeJSON(w, http.StatusOK, album) +} + +func (h *Handlers) EditAlbum(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + albumIDStr := chi.URLParam(r, "id") + albumID, err := parseUUID(albumIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid album ID") + return + } + + var update struct { + Monitored *bool `json:"monitored"` + } + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if update.Monitored != nil { + if err := h.DB.UpdateAlbumMonitored(r.Context(), albumID, *update.Monitored); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if *update.Monitored { + hasFiles, _ := h.DB.HasTrackFiles(r.Context(), albumID) + if !hasFiles { + h.DB.AddToWantedAlbums(r.Context(), albumID) + } + } else { + h.DB.RemoveFromWantedAlbums(r.Context(), albumID) + } + } + + album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID) + if err != nil { + writeError(w, http.StatusNotFound, "album not found") + return + } + + writeJSON(w, http.StatusOK, album) +} + +func (h *Handlers) SearchAlbum(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + albumIDStr := chi.URLParam(r, "id") + albumID, err := parseUUID(albumIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid album ID") + return + } + + result, err := services.SearchAlbum(r.Context(), albumID, h.DB, h.IndexerService) + if err != nil { + writeError(w, http.StatusNotFound, "album not found") + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/api/handlers_artist.go b/internal/api/handlers_artist.go new file mode 100644 index 0000000..1fa2102 --- /dev/null +++ b/internal/api/handlers_artist.go @@ -0,0 +1,219 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/fujin/music-agregator/internal/database" + "github.com/fujin/music-agregator/internal/services" + "github.com/go-chi/chi/v5" +) + +func (h *Handlers) SearchArtists(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Limit int32 `json:"limit,omitempty"` + Offset int32 `json:"offset,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Limit == 0 { + req.Limit = 10 + } + + result, err := h.MetadataClient.SearchArtists(r.Context(), req.Query, req.Limit, req.Offset) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) GetArtistAlbums(w http.ResponseWriter, r *http.Request) { + artistID := chi.URLParam(r, "id") + + result, err := h.MetadataClient.GetArtistAlbums(r.Context(), artistID, 500, 0) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) GetArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + artist, err := h.DB.GetArtistByForeignID(r.Context(), artistID) + if err != nil { + writeError(w, http.StatusNotFound, "artist not found: "+artistID) + return + } + + writeJSON(w, http.StatusOK, artist) +} + +func (h *Handlers) EditArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + var update database.ArtistUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + artist, err := h.DB.UpdateArtistByForeignID(r.Context(), artistID, update) + if err != nil { + writeError(w, http.StatusNotFound, "artist not found: "+artistID) + return + } + + writeJSON(w, http.StatusOK, artist) +} + +func (h *Handlers) DeleteArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + deleted, err := h.DB.DeleteArtistByForeignID(r.Context(), artistID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if !deleted { + writeError(w, http.StatusNotFound, "artist not found: "+artistID) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "deleted": true, + "message": "artist and related data deleted", + }) +} + +func (h *Handlers) RefreshArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + result, err := services.RefreshArtist(r.Context(), artistID, h.MetadataClient, h.DB) + if err != nil { + if _, ok := err.(*services.NotFoundError); ok { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) BulkMonitorArtistAlbums(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + var req struct { + Monitored bool `json:"monitored"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + artist, err := h.DB.GetArtistMetadataByForeignID(r.Context(), artistID) + if err != nil { + writeError(w, http.StatusNotFound, "artist not found") + return + } + + updatedCount, err := h.DB.BulkUpdateAlbumsMonitored(r.Context(), artist.ID, req.Monitored) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + albums, _ := h.DB.ListAlbumsByArtist(r.Context(), artist.ID) + for _, album := range albums { + if req.Monitored { + hasFiles, _ := h.DB.HasTrackFiles(r.Context(), album.ID) + if !hasFiles { + h.DB.AddToWantedAlbums(r.Context(), album.ID) + } + } else { + h.DB.RemoveFromWantedAlbums(r.Context(), album.ID) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "updated_count": updatedCount, + "monitored": req.Monitored, + }) +} + +func (h *Handlers) SearchArtistAlbums(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + result, err := services.SearchArtistAlbums(r.Context(), artistID, h.DB, h.IndexerService) + if err != nil { + writeError(w, http.StatusNotFound, "artist not found") + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/api/handlers_indexer.go b/internal/api/handlers_indexer.go new file mode 100644 index 0000000..41142a9 --- /dev/null +++ b/internal/api/handlers_indexer.go @@ -0,0 +1,47 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/fujin/music-agregator/internal/indexer" +) + +func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) { + indexers := h.IndexerService.GetIndexers(r.Context()) + writeJSON(w, http.StatusOK, indexers) +} + +func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) { + var req struct { + Artist string `json:"artist"` + Album *string `json:"album,omitempty"` + Year *uint32 `json:"year,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Limit == 0 { + req.Limit = 20 + } + + criteria := &indexer.MusicSearchCriteria{ + Artist: req.Artist, + Album: req.Album, + Year: req.Year, + Limit: req.Limit, + Offset: req.Offset, + } + + results, err := h.IndexerService.Search(r.Context(), criteria, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, results) +} diff --git a/internal/api/handlers_library.go b/internal/api/handlers_library.go new file mode 100644 index 0000000..ee77a59 --- /dev/null +++ b/internal/api/handlers_library.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" +) + +func (h *Handlers) ListLibraryArtists(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artists, err := h.DB.ListArtists(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, artists) +} + +func (h *Handlers) ListLibraryAlbums(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + albums, err := h.DB.ListAllAlbums(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, albums) +} + +func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistCount, err := h.DB.CountArtists(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + albumCount, err := h.DB.CountAlbums(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]int64{ + "artists": artistCount, + "albums": albumCount, + }) +} diff --git a/internal/api/handlers_queue.go b/internal/api/handlers_queue.go new file mode 100644 index 0000000..e9ebae8 --- /dev/null +++ b/internal/api/handlers_queue.go @@ -0,0 +1,277 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/fujin/music-agregator/internal/services" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +func (h *Handlers) ListQueue(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + var status *string + if s := r.URL.Query().Get("status"); s != "" { + status = &s + } + + items, err := h.DB.ListDownloadQueue(r.Context(), status) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "total": len(items), + }) +} + +func (h *Handlers) GetQueueItem(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + idStr := chi.URLParam(r, "id") + id, err := parseUUID(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid ID") + return + } + + item, err := h.DB.GetDownloadQueueItem(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "queue item not found") + return + } + + writeJSON(w, http.StatusOK, item) +} + +func (h *Handlers) AddToQueue(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + var req struct { + Title string `json:"title"` + TorrentHash *string `json:"torrent_hash"` + Size int64 `json:"size"` + Indexer *string `json:"indexer"` + AlbumID *string `json:"album_id"` + ArtistID *string `json:"artist_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + var albumID, artistID *uuid.UUID + if req.AlbumID != nil { + if id, err := parseUUID(*req.AlbumID); err == nil { + albumID = &id + } + } + if req.ArtistID != nil { + if id, err := parseUUID(*req.ArtistID); err == nil { + artistID = &id + } + } + + id, err := h.DB.AddToDownloadQueue(r.Context(), req.Title, req.Size, req.TorrentHash, req.Indexer, albumID, artistID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + item, _ := h.DB.GetDownloadQueueItem(r.Context(), id) + writeJSON(w, http.StatusOK, item) +} + +func (h *Handlers) UpdateQueueItem(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + idStr := chi.URLParam(r, "id") + id, err := parseUUID(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid ID") + return + } + + var req struct { + Status *string `json:"status"` + ErrorMessage *string `json:"error_message"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Status != nil { + if *req.Status == "failed" && req.ErrorMessage != nil { + services.HandleFailedDownload(r.Context(), h.DB, id, *req.ErrorMessage) + } else { + if err := h.DB.UpdateDownloadQueueStatus(r.Context(), id, *req.Status, req.ErrorMessage); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if *req.Status == "completed" { + item, _ := h.DB.GetDownloadQueueItem(r.Context(), id) + if item != nil && item.AlbumID != nil { + h.DB.RemoveFromWantedAlbums(r.Context(), *item.AlbumID) + } + } + } + } + + item, err := h.DB.GetDownloadQueueItem(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "queue item not found") + return + } + + writeJSON(w, http.StatusOK, item) +} + +func (h *Handlers) DeleteQueueItem(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + idStr := chi.URLParam(r, "id") + id, err := parseUUID(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid ID") + return + } + + item, err := h.DB.GetDownloadQueueItem(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "queue item not found") + return + } + + if item.TorrentHash != nil && h.TorrentService.IsConfigured() { + h.TorrentService.RemoveTorrent(r.Context(), *item.TorrentHash, false) + } + + if err := h.DB.DeleteDownloadQueueItem(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) +} + +func (h *Handlers) SyncQueue(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + result, err := services.SyncDownloadQueue(r.Context(), h.DB, h.TorrentService) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) BlocklistQueueItem(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + idStr := chi.URLParam(r, "id") + id, err := parseUUID(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid ID") + return + } + + result, err := services.BlocklistAndRemove(r.Context(), h.DB, h.TorrentService, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) QueueStats(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + stats, err := h.DB.GetDownloadQueueStats(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, stats) +} + +func (h *Handlers) GetJobStatus(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + idStr := chi.URLParam(r, "id") + id, err := parseUUID(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid job ID") + return + } + + status, err := services.GetJobStatus(r.Context(), h.DB, h.TorrentService, id) + if err != nil { + writeError(w, http.StatusNotFound, "job not found") + return + } + + writeJSON(w, http.StatusOK, status) +} + +func (h *Handlers) ImportQueueItem(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + if h.StorageBasePath == "" { + writeError(w, http.StatusServiceUnavailable, "storage not configured") + return + } + + idStr := chi.URLParam(r, "id") + id, err := parseUUID(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid ID") + return + } + + result, err := services.ImportCompletedDownload(r.Context(), id, h.StorageBasePath, h.DB, h.TorrentService) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/api/handlers_sync.go b/internal/api/handlers_sync.go new file mode 100644 index 0000000..0163f7c --- /dev/null +++ b/internal/api/handlers_sync.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/fujin/music-agregator/internal/services" +) + +func (h *Handlers) Sync(w http.ResponseWriter, r *http.Request) { + var req struct { + Artist string `json:"artist"` + Album *string `json:"album,omitempty"` + Download *bool `json:"download,omitempty"` + Store *bool `json:"store,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + download := true + if req.Download != nil { + download = *req.Download + } + store := true + if req.Store != nil { + store = *req.Store + } + + options := services.SyncOptions{ + Artist: req.Artist, + Album: req.Album, + Download: download, + Store: store, + } + + result, err := services.Sync(r.Context(), options, h.MetadataClient, h.IndexerService, h.TorrentService, h.DB) + if err != nil { + if _, ok := err.(*services.NotFoundError); ok { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) AddToBlocklist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + var req struct { + AlbumID string `json:"album_id"` + SourceTitle string `json:"source_title"` + GUID *string `json:"guid"` + TorrentHash *string `json:"torrent_hash"` + Indexer *string `json:"indexer"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + albumID, err := parseUUID(req.AlbumID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid album_id") + return + } + + artistID, err := h.DB.GetArtistIDByAlbum(r.Context(), albumID) + if err != nil { + writeError(w, http.StatusNotFound, "album not found") + return + } + + if err := h.DB.AddToBlocklist(r.Context(), *artistID, albumID, req.SourceTitle, req.TorrentHash, req.Indexer); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "added": true, + }) +} diff --git a/internal/api/handlers_torrent.go b/internal/api/handlers_torrent.go new file mode 100644 index 0000000..52bb204 --- /dev/null +++ b/internal/api/handlers_torrent.go @@ -0,0 +1,79 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (h *Handlers) ListTorrents(w http.ResponseWriter, r *http.Request) { + torrents, err := h.TorrentService.ListTorrents(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, torrents) +} + +func (h *Handlers) GetTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + torrent, err := h.TorrentService.GetTorrent(r.Context(), hash) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, torrent) +} + +func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) { + var req struct { + URL string `json:"url"` + SavePath *string `json:"save_path,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := h.TorrentService.AddTorrentURL(r.Context(), req.URL, req.SavePath); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "added"}) +} + +func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + + var req struct { + DeleteFiles bool `json:"delete_files"` + } + json.NewDecoder(r.Body).Decode(&req) + + if err := h.TorrentService.RemoveTorrent(r.Context(), hash, req.DeleteFiles); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) +} + +func (h *Handlers) PauseTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + if err := h.TorrentService.PauseTorrent(r.Context(), hash); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "paused"}) +} + +func (h *Handlers) ResumeTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + if err := h.TorrentService.ResumeTorrent(r.Context(), hash); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"}) +} diff --git a/internal/api/router.go b/internal/api/router.go index 677c77d..301ca48 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -69,6 +69,7 @@ func NewRouter(h *Handlers) *chi.Mux { 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) { @@ -76,6 +77,8 @@ func NewRouter(h *Handlers) *chi.Mux { r.Get("/albums", h.ListLibraryAlbums) r.Get("/stats", h.LibraryStats) }) + + r.Get("/job/{id}", h.GetJobStatus) }) return r diff --git a/internal/config/config.go b/internal/config/config.go index 8cdf15e..905a5b9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,11 @@ type Config struct { 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 { @@ -88,5 +93,9 @@ func Load(path string) (*Config, error) { cfg.Torrent.SavePath = "/tmp/downloads" } + if cfg.Storage.BasePath == "" { + cfg.Storage.BasePath = "/music" + } + return &cfg, nil } diff --git a/internal/database/db.go b/internal/database/db.go index 215954e..fd5268d 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -718,6 +718,11 @@ func (db *DB) UpdateDownloadQueueProgress(ctx context.Context, id uuid.UUID, pro 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 diff --git a/internal/services/album.go b/internal/services/album.go index b8bb3bc..11624cb 100644 --- a/internal/services/album.go +++ b/internal/services/album.go @@ -8,6 +8,7 @@ import ( "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 { @@ -30,11 +31,16 @@ func SearchAlbum( 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()) @@ -51,13 +57,18 @@ func SearchAlbum( 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 } @@ -71,10 +82,30 @@ func SearchAlbum( }) } + 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, diff --git a/internal/services/download.go b/internal/services/download.go index 7055cba..614071e 100644 --- a/internal/services/download.go +++ b/internal/services/download.go @@ -1,13 +1,20 @@ 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" ) @@ -36,6 +43,7 @@ type AlbumSyncResult struct { 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"` } @@ -53,6 +61,18 @@ type downloadResult struct { 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( @@ -153,7 +173,7 @@ func Sync( } var downloadStatus *DownloadStatus - var torrentHash, indexerName, dlError *string + var torrentHash, indexerName, dlError, jobID *string if options.Download { var year *uint32 @@ -167,11 +187,38 @@ func Sync( } } - dlResult := downloadAlbum(ctx, artist.Name, album.Title, year, indexerService, torrentService) + 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: @@ -190,6 +237,7 @@ func Sync( DownloadStatus: downloadStatus, TorrentHash: torrentHash, Indexer: indexerName, + JobID: jobID, Error: dlError, }) } @@ -206,39 +254,63 @@ func Sync( }, nil } -func downloadAlbum( - ctx context.Context, - artistName, albumTitle string, - year *uint32, - indexerService *IndexerService, - torrentService *TorrentService, -) downloadResult { - albumStr := albumTitle +func downloadAlbum(ctx context.Context, dlCtx *downloadContext) downloadResult { + albumStr := dlCtx.albumTitle criteria := &indexer.MusicSearchCriteria{ - Artist: artistName, + Artist: dlCtx.artistName, Album: &albumStr, - Year: year, + Year: dlCtx.year, Limit: 20, Offset: 0, } - searchResults, err := indexerService.Search(ctx, criteria, nil) + 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) - if err := torrentService.AddTorrentURL(ctx, best.DownloadURL, nil); err != nil { - errStr := "failed to add torrent: " + err.Error() + 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, @@ -246,10 +318,41 @@ func downloadAlbum( } } + 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: best.Infohash, + torrentHash: &infoHash, indexer: &best.Indexer, + queueID: queueIDStr, } } @@ -277,6 +380,47 @@ func selectBestResult(results []indexer.SearchResult) *indexer.SearchResult { 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, "-", "") diff --git a/internal/services/import.go b/internal/services/import.go new file mode 100644 index 0000000..3b6623c --- /dev/null +++ b/internal/services/import.go @@ -0,0 +1,234 @@ +package services + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/fujin/music-agregator/internal/database" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +type ImportResult struct { + QueueID string `json:"queue_id"` + ArtistName string `json:"artist_name"` + AlbumTitle string `json:"album_title"` + TargetPath string `json:"target_path"` + FilesCopied int `json:"files_copied"` + TotalSize int64 `json:"total_size"` + Files []string `json:"files"` +} + +func ImportCompletedDownload( + ctx context.Context, + queueID uuid.UUID, + basePath string, + db *database.DB, + torrentService *TorrentService, +) (*ImportResult, error) { + log.Info().Str("queue_id", queueID.String()).Str("base_path", basePath).Msg("[IMPORT] starting import") + + item, err := db.GetDownloadQueueItem(ctx, queueID) + if err != nil { + log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[IMPORT] queue item not found") + return nil, fmt.Errorf("queue item not found: %w", err) + } + + log.Info().Str("title", item.Title).Str("status", item.Status).Msg("[IMPORT] found queue item") + + if item.Status != "completed" && item.Status != "seeding" { + log.Error().Str("status", item.Status).Msg("[IMPORT] download not completed") + return nil, fmt.Errorf("download not completed, status: %s", item.Status) + } + + if item.TorrentHash == nil { + log.Error().Msg("[IMPORT] no torrent hash for queue item") + return nil, fmt.Errorf("no torrent hash for queue item") + } + + log.Info().Str("hash", *item.TorrentHash).Msg("[IMPORT] fetching torrent info") + torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash) + if err != nil { + log.Error().Err(err).Str("hash", *item.TorrentHash).Msg("[IMPORT] torrent not found") + return nil, fmt.Errorf("torrent not found: %w", err) + } + + log.Info().Str("name", torrent.Name).Str("save_path", torrent.SavePath).Msg("[IMPORT] torrent info retrieved") + + var artistName, albumTitle string + if item.AlbumID != nil { + album, err := db.GetAlbumDetailByID(ctx, *item.AlbumID) + if err == nil { + artistName = album.ArtistName + albumTitle = album.Title + log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] resolved from database") + } + } + + if artistName == "" || albumTitle == "" { + parts := strings.SplitN(item.Title, " - ", 2) + if len(parts) == 2 { + artistName = parts[0] + albumTitle = parts[1] + } else { + artistName = "Unknown Artist" + albumTitle = item.Title + } + log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] parsed from title") + } + + artistName = sanitizePath(artistName) + albumTitle = sanitizePath(albumTitle) + + targetDir := filepath.Join(basePath, artistName, albumTitle) + log.Info().Str("target_dir", targetDir).Msg("[IMPORT] creating target directory") + if err := os.MkdirAll(targetDir, 0755); err != nil { + log.Error().Err(err).Str("target_dir", targetDir).Msg("[IMPORT] failed to create target directory") + return nil, fmt.Errorf("failed to create target directory: %w", err) + } + + sourcePath := filepath.Join(torrent.SavePath, torrent.Name) + log.Info().Str("source_path", sourcePath).Msg("[IMPORT] checking source path") + + var filesCopied int + var totalSize int64 + var copiedFiles []string + + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + log.Error().Err(err).Str("source_path", sourcePath).Msg("[IMPORT] source path not found") + return nil, fmt.Errorf("source path not found: %w", err) + } + + if sourceInfo.IsDir() { + log.Info().Str("source_path", sourcePath).Msg("[IMPORT] source is directory, walking files") + err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + if !isAudioFile(info.Name()) { + log.Debug().Str("file", info.Name()).Msg("[IMPORT] skipping non-audio file") + return nil + } + + relPath, _ := filepath.Rel(sourcePath, path) + targetPath := filepath.Join(targetDir, relPath) + + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return err + } + + log.Info().Str("src", path).Str("dst", targetPath).Msg("[IMPORT] copying file") + if err := copyFile(path, targetPath); err != nil { + log.Warn().Err(err).Str("file", path).Msg("[IMPORT] failed to copy file") + return nil + } + + filesCopied++ + totalSize += info.Size() + copiedFiles = append(copiedFiles, relPath) + return nil + }) + if err != nil { + log.Error().Err(err).Msg("[IMPORT] failed to copy files") + return nil, fmt.Errorf("failed to copy files: %w", err) + } + } else { + if isAudioFile(sourceInfo.Name()) { + targetPath := filepath.Join(targetDir, sourceInfo.Name()) + log.Info().Str("src", sourcePath).Str("dst", targetPath).Msg("[IMPORT] copying single file") + if err := copyFile(sourcePath, targetPath); err != nil { + log.Error().Err(err).Msg("[IMPORT] failed to copy file") + return nil, fmt.Errorf("failed to copy file: %w", err) + } + filesCopied = 1 + totalSize = sourceInfo.Size() + copiedFiles = append(copiedFiles, sourceInfo.Name()) + } + } + + log.Info().Int("files_copied", filesCopied).Int64("total_size", totalSize).Msg("[IMPORT] file copy completed") + + log.Info().Msg("[IMPORT] updating queue status to imported") + if err := db.UpdateDownloadQueueStatus(ctx, queueID, "imported", nil); err != nil { + log.Warn().Err(err).Msg("[IMPORT] failed to update queue status to imported") + } + + if item.AlbumID != nil { + log.Info().Msg("[IMPORT] removing from wanted albums") + db.RemoveFromWantedAlbums(ctx, *item.AlbumID) + } + + log.Info(). + Str("artist", artistName). + Str("album", albumTitle). + Str("target_path", targetDir). + Int("files_copied", filesCopied). + Msg("[IMPORT] import completed successfully") + + return &ImportResult{ + QueueID: queueID.String(), + ArtistName: artistName, + AlbumTitle: albumTitle, + TargetPath: targetDir, + FilesCopied: filesCopied, + TotalSize: totalSize, + Files: copiedFiles, + }, nil +} + +var pathSanitizeRegex = regexp.MustCompile(`[<>:"/\\|?*]`) + +func sanitizePath(s string) string { + s = pathSanitizeRegex.ReplaceAllString(s, "_") + s = strings.TrimSpace(s) + if s == "" { + s = "Unknown" + } + return s +} + +func isAudioFile(name string) bool { + ext := strings.ToLower(filepath.Ext(name)) + audioExts := map[string]bool{ + ".flac": true, + ".mp3": true, + ".m4a": true, + ".aac": true, + ".ogg": true, + ".opus": true, + ".wav": true, + ".wma": true, + ".alac": true, + } + return audioExts[ext] +} + +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + if _, err := io.Copy(destFile, sourceFile); err != nil { + return err + } + + return destFile.Sync() +} diff --git a/internal/services/indexer.go b/internal/services/indexer.go index 819dfab..3763c1f 100644 --- a/internal/services/indexer.go +++ b/internal/services/indexer.go @@ -7,6 +7,7 @@ import ( "github.com/fujin/music-agregator/internal/config" "github.com/fujin/music-agregator/internal/indexer" + "github.com/rs/zerolog/log" ) type IndexerService struct { @@ -31,7 +32,25 @@ func NewIndexerService(configs []config.IndexerConfig) (*IndexerService, error) indexers = append(indexers, idx) } - return &IndexerService{indexers: indexers}, nil + 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 { @@ -54,18 +73,37 @@ func buildTorznabURL(cfg config.IndexerConfig) string { 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 } diff --git a/internal/services/queue.go b/internal/services/queue.go index fbd861e..562e863 100644 --- a/internal/services/queue.go +++ b/internal/services/queue.go @@ -2,10 +2,12 @@ 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 { @@ -14,52 +16,106 @@ type QueueSyncResult struct { } 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 { - if item.TorrentHash == nil { + 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++ - t, exists := torrentMap[*item.TorrentHash] - if !exists { - continue - } 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 } @@ -83,27 +139,37 @@ func mapTorrentState(state torrent.TorrentState) string { } 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 } @@ -112,12 +178,78 @@ type BlocklistResult struct { Removed bool `json:"removed"` } -func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *TorrentService, queueID uuid.UUID) (*BlocklistResult, error) { - item, err := db.GetDownloadQueueItem(ctx, queueID) +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 { @@ -125,23 +257,32 @@ func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *To if albumID == nil { albumID = &uuid.Nil } + log.Info().Str("title", item.Title).Msg("[BLOCKLIST] adding to blocklist") if err := db.AddToBlocklist(ctx, *item.ArtistID, *albumID, item.Title, item.TorrentHash, item.Indexer); err == nil { result.Blocklisted = true + log.Info().Str("title", item.Title).Msg("[BLOCKLIST] added to blocklist") + } else { + log.Warn().Err(err).Str("title", item.Title).Msg("[BLOCKLIST] failed to add to blocklist") } } if item.TorrentHash != nil && torrentService.IsConfigured() { + log.Info().Str("hash", *item.TorrentHash).Msg("[BLOCKLIST] removing torrent from client") torrentService.RemoveTorrent(ctx, *item.TorrentHash, true) } + log.Info().Str("title", item.Title).Msg("[BLOCKLIST] deleting from queue") if err := db.DeleteDownloadQueueItem(ctx, queueID); err != nil { + log.Error().Err(err).Msg("[BLOCKLIST] failed to delete queue item") return nil, err } result.Removed = true if item.AlbumID != nil { + log.Info().Str("title", item.Title).Msg("[BLOCKLIST] re-adding album to wanted list") db.AddToWantedAlbums(ctx, *item.AlbumID) } + log.Info().Bool("blocklisted", result.Blocklisted).Bool("removed", result.Removed).Msg("[BLOCKLIST] completed") return result, nil } diff --git a/internal/services/torrent.go b/internal/services/torrent.go index a479ec8..ffb89a7 100644 --- a/internal/services/torrent.go +++ b/internal/services/torrent.go @@ -96,3 +96,10 @@ func (s *TorrentService) ResumeTorrent(ctx context.Context, hash string) error { func (s *TorrentService) IsConfigured() bool { return s.client != nil } + +func (s *TorrentService) GetStubClient() *torrent.StubClient { + if stub, ok := s.client.(*torrent.StubClient); ok { + return stub + } + return nil +} diff --git a/internal/torrent/qbittorrent.go b/internal/torrent/qbittorrent.go index bca47bb..fb771cc 100644 --- a/internal/torrent/qbittorrent.go +++ b/internal/torrent/qbittorrent.go @@ -12,6 +12,8 @@ import ( "net/url" "strings" "sync" + + "github.com/rs/zerolog/log" ) type QBittorrentClient struct { @@ -54,7 +56,7 @@ func (c *QBittorrentClient) apiURL(path string) string { func (c *QBittorrentClient) mapState(state string) TorrentState { switch state { - case "downloading", "forcedDL", "metaDL", "allocating": + case "downloading", "forcedDL", "metaDL", "allocating", "stalledDL": return StateDownloading case "uploading", "forcedUP", "stalledUP": return StateSeeding @@ -209,6 +211,8 @@ func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string return err } + log.Debug().Str("url", torrentURL).Msg("[QBITTORRENT] adding torrent URL") + var buf bytes.Buffer w := multipart.NewWriter(&buf) w.WriteField("urls", torrentURL) @@ -225,15 +229,27 @@ func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string 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) { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body)) + 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 } diff --git a/internal/torrent/stub.go b/internal/torrent/stub.go index 8ad935f..5750c16 100644 --- a/internal/torrent/stub.go +++ b/internal/torrent/stub.go @@ -2,6 +2,8 @@ package torrent import ( "context" + "crypto/sha1" + "encoding/hex" "fmt" "os" "sync" @@ -11,19 +13,25 @@ import ( type StubClient struct { logPath string savePath string - mu sync.Mutex + 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) { - c.mu.Lock() - defer c.mu.Unlock() + 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 { @@ -47,13 +55,29 @@ func (c *StubClient) Disconnect(ctx context.Context) error { } func (c *StubClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) { - c.log("LIST_TORRENTS") - return []TorrentInfo{}, nil + 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) - return nil, ErrTorrentNotFound + + 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 { @@ -61,7 +85,24 @@ func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *st if savePath != nil { path = *savePath } - c.log("ADD_TORRENT_URL url=%s save_path=%s", url, path) + + 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 } @@ -70,21 +111,117 @@ func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath * if savePath != nil { path = *savePath } - c.log("ADD_TORRENT_FILE size=%d save_path=%s", len(data), path) + + 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)) +}