diff --git a/docs/architecture/flows/monitor-album-error-already-owned.png b/docs/architecture/flows/monitor-album-error-already-owned.png new file mode 100644 index 0000000..8506273 Binary files /dev/null and b/docs/architecture/flows/monitor-album-error-already-owned.png differ diff --git a/docs/architecture/flows/monitor-album-error-already-owned.puml b/docs/architecture/flows/monitor-album-error-already-owned.puml new file mode 100644 index 0000000..f3d947c --- /dev/null +++ b/docs/architecture/flows/monitor-album-error-already-owned.puml @@ -0,0 +1,39 @@ +@startuml MonitorAlbum - Already Owned (Early Return) +skinparam sequenceMessageAlign center +skinparam responseMessageBelowArrow true +title MonitorAlbum: Album Already Owned in Requested Quality + +actor Client +participant "MusicAgregatorService" as Service +participant "MetadataService" as Metadata +database "metadata-agregator\n(gRPC)" as MetaGRPC +database "PostgreSQL" as DB + +Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS) +Service -> Metadata: GetAlbum(album_id) +Metadata -> MetaGRPC: GetAlbum(id) +MetaGRPC --> Metadata: Album +Metadata -> DB: albums.GetByExternalID() +DB --> Metadata: found (already persisted) +note right: Album exists from\nprevious MonitorAlbum\nor GetArtists discovery +Metadata --> Service: Album + +Service -> DB: albums.GetByExternalID() +DB --> Service: dbAlbum +Service -> DB: albums.SetMonitorState(id, monitored) +note right #lightgreen: Always set monitored\nregardless of outcome + +Service -> DB: downloads.HasAlbumInQuality(id, LOSSLESS, "16-44") +DB --> Service: true +note right #lightgreen: Found existing download\nwith state IN\n('completed', 'seeding') + +Service -> Service: buildMonitorAlbumResponse() +Service -> DB: downloads.GetByAlbumID() +DB --> Service: download (completed, FLAC, /downloads) +Service -> DB: artists.GetByExternalID() +DB --> Service: artist + +Service --> Client: MonitorAlbumResponse +note right #lightgreen: album: monitored, download info\nartist: monitored\nrelease: nil (no new search)\n\nNo indexer search.\nNo torrent client interaction.\nFast response. + +@enduml diff --git a/docs/architecture/flows/monitor-album-error-indexer.png b/docs/architecture/flows/monitor-album-error-indexer.png new file mode 100644 index 0000000..21e15da Binary files /dev/null and b/docs/architecture/flows/monitor-album-error-indexer.png differ diff --git a/docs/architecture/flows/monitor-album-error-indexer.puml b/docs/architecture/flows/monitor-album-error-indexer.puml new file mode 100644 index 0000000..6262d63 --- /dev/null +++ b/docs/architecture/flows/monitor-album-error-indexer.puml @@ -0,0 +1,74 @@ +@startuml MonitorAlbum - Indexer & Parse Failures +skinparam sequenceMessageAlign center +skinparam responseMessageBelowArrow true +title MonitorAlbum Error: Indexer Search & Parse Failures + +actor Client +participant "MusicAgregatorService" as Service +participant "IndexerService\n(Jackett)" as Indexer +participant "MagnetResolver" as Magnet + +== Case 1: Indexer search fails (Jackett down / timeout) == + +Client -> Service: MonitorAlbum(album_id, quality) +note right: metadata fetch + persist succeeded + +Service -> Indexer: Search("Artist Album", tracker) +Indexer --> Service: error (connection refused / timeout) +Service --> Client: error +note right #salmon: Full stop after metadata.\nAlbum is persisted & monitored\nbut no torrent found. + +== Case 2: Indexer returns zero results == + +Client -> Service: MonitorAlbum(album_id, quality) +Service -> Indexer: Search("Artist Album", tracker) +Indexer --> Service: SearchResponse (0 items) +Service -> Service: parseSearchResults → empty +Service -> Service: filterByQuality → empty +Service --> Client: MonitorAlbumResponse +note right #orange: Partial response.\nalbum + artist returned.\nrelease: nil.\nNo torrent added. + +== Case 3: All results have no seeders == + +Client -> Service: MonitorAlbum(album_id, quality) +Service -> Indexer: Search(...) +Indexer --> Service: SearchResponse (5 items, all seeders=0) + +loop for each item + Service -> Service: item.Seeders == 0 → skip + note right: Logged as warning per item +end + +Service -> Service: parsed = empty +Service -> Service: filterByQuality → empty +Service --> Client: MonitorAlbumResponse (no release) + +== Case 4: All magnet resolves fail == + +Client -> Service: MonitorAlbum(album_id, quality) +Service -> Indexer: Search(...) +Indexer --> Service: SearchResponse (3 items) + +loop for each item + Service -> Magnet: Resolve(magnet_uri) + Magnet --> Service: error (timeout / no active peers) + Service -> Service: fallback to title parse + note right: Release parsed from title only.\nFormat may be "unknown".\nNo torrent data (nil). +end + +Service -> Service: filterByQuality(parsed, LOSSLESS) +note right #orange: Title-parsed releases may have\nformat=unknown → not lossless\n→ filtered out +Service --> Client: MonitorAlbumResponse (no release) + +== Case 5: No results match quality filter == + +Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS) +Service -> Indexer: Search(...) +Indexer --> Service: SearchResponse (3 items) +Service -> Service: parseSearchResults → 3 items (all MP3) +Service -> Service: filterByQuality(LOSSLESS) → empty +note right: All releases are lossy.\nQuality filter rejects all. +Service --> Client: MonitorAlbumResponse +note right #orange: album + artist returned.\nrelease: nil.\n"no releases match quality filter" + +@enduml diff --git a/docs/architecture/flows/monitor-album-error-metadata.png b/docs/architecture/flows/monitor-album-error-metadata.png new file mode 100644 index 0000000..39e8b42 Binary files /dev/null and b/docs/architecture/flows/monitor-album-error-metadata.png differ diff --git a/docs/architecture/flows/monitor-album-error-metadata.puml b/docs/architecture/flows/monitor-album-error-metadata.puml new file mode 100644 index 0000000..d0911cf --- /dev/null +++ b/docs/architecture/flows/monitor-album-error-metadata.puml @@ -0,0 +1,58 @@ +@startuml MonitorAlbum - Metadata Fetch Failures +skinparam sequenceMessageAlign center +skinparam responseMessageBelowArrow true +title MonitorAlbum Error: Metadata Fetch Failures + +actor Client +participant "MusicAgregatorService" as Service +participant "MetadataService" as Metadata +database "metadata-agregator\n(gRPC)" as MetaGRPC +database "PostgreSQL" as DB + +== Case 1: metadata-agregator unreachable == + +Client -> Service: MonitorAlbum(album_id, quality) +Service -> Metadata: GetAlbum(album_id) +Metadata -> MetaGRPC: GetAlbum(id) +MetaGRPC --> Metadata: gRPC error (Unavailable) +Metadata --> Service: error: "fetching album: ..." +Service --> Client: error (Unavailable) +note right: Full stop.\nNo DB writes occur.\nNo indexer search. + +== Case 2: Album not found in metadata == + +Client -> Service: MonitorAlbum(album_id, quality) +Service -> Metadata: GetAlbum(album_id) +Metadata -> MetaGRPC: GetAlbum(id) +MetaGRPC --> Metadata: gRPC error (NotFound) +Metadata --> Service: error: "fetching album: ..." +Service --> Client: error (NotFound) +note right: Full stop.\nInvalid album_id.\nNo side effects. + +== Case 3: Album found, but artist persist fails == + +Client -> Service: MonitorAlbum(album_id, quality) +Service -> Metadata: GetAlbum(album_id) +Metadata -> MetaGRPC: GetAlbum(id) +MetaGRPC --> Metadata: Album + +Metadata -> DB: albums.GetByExternalID() +DB --> Metadata: not found + +Metadata -> DB: artists.Create() +DB --> Metadata: error (e.g. connection lost) +note right #salmon: Artist persist fails.\nLogged as warning.\nFlow continues. + +Metadata -> DB: albums.Create() +note right #salmon: artistID is empty\n→ album persist skipped\n(no artist reference) + +Metadata --> Service: Album (metadata only) + +Service -> DB: albums.GetByExternalID() +DB --> Service: not found (album never persisted) +note right #salmon: dbAlbum is nil.\nMonitor state not set.\nOwnership check skipped. + +Service -> Service: continues to indexer search... +note right: Flow proceeds but\ndownload persistence\nwill be skipped later\n(dbAlbum == nil) + +@enduml diff --git a/docs/architecture/flows/monitor-album-error-poll-worker.png b/docs/architecture/flows/monitor-album-error-poll-worker.png new file mode 100644 index 0000000..783c2f2 Binary files /dev/null and b/docs/architecture/flows/monitor-album-error-poll-worker.png differ diff --git a/docs/architecture/flows/monitor-album-error-poll-worker.puml b/docs/architecture/flows/monitor-album-error-poll-worker.puml new file mode 100644 index 0000000..8b94e87 --- /dev/null +++ b/docs/architecture/flows/monitor-album-error-poll-worker.puml @@ -0,0 +1,90 @@ +@startuml MonitorAlbum - Async Poll Worker Failures +skinparam sequenceMessageAlign center +skinparam responseMessageBelowArrow true +title MonitorAlbum: Async Download Poll Worker Failures + +participant "River Queue" as River +participant "PollDownloadWorker" as Worker +participant "TorrentClient\n(qBittorrent)" as QBit +database "PostgreSQL" as DB + +note over Worker: These occur asynchronously\nafter MonitorAlbum returns.\nClient already received response. + +== Case 1: qBittorrent unreachable during poll == + +River -> Worker: Work(PollDownloadArgs) +Worker -> QBit: Find(hash) +QBit --> Worker: error (connection refused) +note right #orange: Logged as error.\nJob rescheduled. + +Worker -> River: Insert(PollDownloadArgs) +note right: Reschedule after check_interval (30s).\nRetries indefinitely until\nqBit becomes available. + +== Case 2: Torrent disappeared from qBittorrent == + +River -> Worker: Work(PollDownloadArgs) +Worker -> QBit: Find(hash) +QBit --> Worker: empty results +note right #salmon: Torrent was removed\nfrom qBit externally. + +Worker -> DB: downloads.SetFailed(id, "torrent not found in client") +note right: Download marked as failed.\nNo further polls scheduled.\nNo retry. + +== Case 3: Torrent in error state == + +River -> Worker: Work(PollDownloadArgs) +Worker -> QBit: Find(hash) +QBit --> Worker: TorrentInfo{state: "error"} +note right #salmon: qBit reports torrent error.\n(e.g. tracker unreachable,\ncorrupt data, disk full) + +Worker -> DB: downloads.SetFailed(id, "torrent error state") +note right: Download marked as failed.\nNo further polls.\nTorrent remains in qBit. + +== Case 4: Download completes, but SetCompleted fails == + +River -> Worker: Work(PollDownloadArgs) +Worker -> QBit: Find(hash) +QBit --> Worker: TorrentInfo{progress: 1.0, savePath: "/downloads"} + +Worker -> DB: downloads.SetCompleted(id, "/downloads") +DB --> Worker: error (DB connection lost) +note right #salmon: Worker returns error.\nRiver will retry the job\n(built-in retry policy).\nDownload stays in\n"downloading" state. + +== Case 5: File scan fails after completion == + +River -> Worker: Work(PollDownloadArgs) +Worker -> QBit: Find(hash) +QBit --> Worker: TorrentInfo{progress: 1.0, path: "/downloads/Album"} + +Worker -> DB: downloads.SetCompleted(id, "/downloads") +DB --> Worker: OK +note right #lightgreen: Download marked completed. + +Worker -> Worker: scanAndHashFiles("/downloads/Album") +Worker --> Worker: error (permission denied / path not found) +note right #orange: Logged as error.\nDownload IS completed.\nBut download_files NOT populated.\nGetAlbum won't show file info\nfor individual tracks. + +== Case 6: File persist fails == + +River -> Worker: Work(PollDownloadArgs) +Worker -> QBit: Find(hash) +QBit --> Worker: TorrentInfo{progress: 1.0} + +Worker -> DB: downloads.SetCompleted(id, savePath) +Worker -> Worker: scanAndHashFiles → 12 files +Worker -> DB: download_files.CreateBatch(files) +DB --> Worker: error (duplicate / constraint) +note right #orange: Download is completed.\nFiles not persisted.\nNon-fatal: returns nil.\nNo retry. + +== Case 7: App crash during download (startup recovery) == + +note over River, Worker: Application restarts.\nRiver picks up persisted jobs. + +River -> Worker: RecoverOrphanedDownloads() +Worker -> DB: downloads.GetActive() +DB --> Worker: [Download{state: downloading, hash: ...}] + +Worker -> River: Insert(PollDownloadArgs, UniqueOpts{ByArgs}) +note right #lightgreen: Deduplicated insert.\nIf River job already exists\n→ no duplicate.\nIf job was lost → recovered.\nPolling resumes. + +@enduml diff --git a/docs/architecture/flows/monitor-album-error-torrent.png b/docs/architecture/flows/monitor-album-error-torrent.png new file mode 100644 index 0000000..fde6aed Binary files /dev/null and b/docs/architecture/flows/monitor-album-error-torrent.png differ diff --git a/docs/architecture/flows/monitor-album-error-torrent.puml b/docs/architecture/flows/monitor-album-error-torrent.puml new file mode 100644 index 0000000..db39ce5 --- /dev/null +++ b/docs/architecture/flows/monitor-album-error-torrent.puml @@ -0,0 +1,71 @@ +@startuml MonitorAlbum - Torrent Client Failures +skinparam sequenceMessageAlign center +skinparam responseMessageBelowArrow true +title MonitorAlbum Error: Torrent Client Failures + +actor Client +participant "MusicAgregatorService" as Service +participant "TorrentClient\n(qBittorrent)" as QBit +database "PostgreSQL" as DB + +note over Service: Metadata fetched, indexer searched,\nbest release selected. + +== Case 1: qBittorrent unreachable == + +Service -> QBit: Find(hash) +QBit --> Service: error (connection refused) +note right: Find() fails → skip existence check + +Service -> QBit: AddMagnet(uri) +QBit --> Service: error (connection refused) +Service --> Client: error +note right #salmon: Album is persisted & monitored.\nNo torrent added.\nNo download record created. + +== Case 2: Torrent already exists in qBit == + +Service -> QBit: Find(hash) +QBit --> Service: [TorrentInfo{state: stalledUP}] +note right #lightgreen: Torrent found.\nSkip AddMagnet/AddTorrent. + +Service -> DB: torrents.Create (upsert) +Service -> DB: torrents.GetByInfoHash +DB --> Service: savedTorrent + +Service -> DB: downloads.GetActiveByTorrentID(torrent_id) +DB --> Service: Download{state: completed} +note right #lightgreen: Active download exists.\nSkip duplicate insert. + +Service --> Client: MonitorAlbumResponse +note right: Returns album + artist + release.\nNo duplicate download created.\nNo error. + +== Case 3: AddTorrent fails (no torrent data) == + +Service -> QBit: Find(hash) +QBit --> Service: not found + +note right: best.torrentData is nil\n(magnet resolve failed,\nfell back to title parse) + +Service -> Service: len(best.torrentData) == 0 +Service --> Client: error "no torrent data available" +note right #salmon: Magnet link but no\ntorrent data resolved.\nCannot add to client. + +== Case 4: Torrent persists, but download insert fails == + +Service -> QBit: AddMagnet(uri) +QBit --> Service: OK + +Service -> DB: torrents.Create (upsert) +Service -> DB: torrents.GetByInfoHash +DB --> Service: savedTorrent + +Service -> DB: downloads.GetActiveByTorrentID +DB --> Service: not found + +Service -> DB: downloads.Create(download) +DB --> Service: error (constraint violation) +note right #salmon: Logged as error.\nTorrent is in qBit but\nno download record.\nResponse still returned\n(non-fatal within saveTorrentAndDownload).\nNo poll job scheduled. + +Service --> Client: MonitorAlbumResponse +note right #orange: Response includes release info.\nDownload info may be missing.\nTorrent is downloading in qBit\nbut untracked in DB. + +@enduml diff --git a/docs/architecture/flows/monitor-album-happy-path.png b/docs/architecture/flows/monitor-album-happy-path.png new file mode 100644 index 0000000..79f2220 Binary files /dev/null and b/docs/architecture/flows/monitor-album-happy-path.png differ diff --git a/docs/architecture/flows/monitor-album-sequence.puml b/docs/architecture/flows/monitor-album-sequence.puml new file mode 100644 index 0000000..478c6f2 --- /dev/null +++ b/docs/architecture/flows/monitor-album-sequence.puml @@ -0,0 +1,138 @@ +@startuml MonitorAlbum Happy Path +skinparam sequenceMessageAlign center +skinparam responseMessageBelowArrow true + +actor Client +participant "gRPC Server" as Server +participant "MusicAgregatorService" as Service +participant "MetadataService" as Metadata +database "metadata-agregator\n(gRPC)" as MetaGRPC +database "PostgreSQL" as DB +participant "IndexerService\n(Jackett)" as Indexer +participant "MagnetResolver" as Magnet +participant "TorrentClient\n(qBittorrent)" as QBit +participant "River Queue" as River +participant "PollDownloadWorker" as PollWorker + +== 1. Fetch Album Metadata == + +Client -> Server: MonitorAlbum(album_id, quality, tracker) +Server -> Service: MonitorAlbum(ctx, req) + +Service -> Metadata: GetAlbum(album_id) +Metadata -> MetaGRPC: GetAlbum(id) +MetaGRPC --> Metadata: Album (title, artists, genres, ...) + +Metadata -> DB: albums.GetByExternalID(external_id) +note right: Check if album already persisted +DB --> Metadata: not found + +Metadata -> DB: artists.Create(artist, state=monitored) +note right: Upsert artist\nnever downgrades\nmonitored/excluded +Metadata -> DB: albums.Create(album, state=monitored) +note right: Upsert album\nnever downgrades\nmonitored/excluded +Metadata --> Service: Album + +== 2. Set Monitor State == + +Service -> DB: albums.GetByExternalID(external_id) +DB --> Service: dbAlbum +Service -> DB: albums.SetMonitorState(id, monitored) +note right: Explicitly mark\nalbum as monitored + +== 3. Check If Already Owned == + +Service -> DB: downloads.HasAlbumInQuality(album_id, format, quality) +DB --> Service: false (not owned) + +== 4. Search Indexers == + +Service -> Indexer: Search(artist + album title, tracker) +Indexer -> Indexer: Jackett API\n/api/v2.0/indexers/all/results +Indexer --> Service: SearchResponse (N items) + +== 5. Parse & Resolve Releases == + +loop for each search result (with download link & seeders > 0) + alt magnet link + Service -> Magnet: Resolve(magnet_uri) + note right: DHT lookup, 30s timeout\n15s early exit if peers\nbut none active + Magnet --> Service: torrent metadata (files, hash, size) + Service -> Service: ParseTorrent(torrentData, album) + else HTTP torrent link + Service -> Service: downloadTorrentData(url) + Service -> Service: ParseTorrent(torrentData, album) + end + note right: Extract: format, bitDepth, sampleRate,\nsource, trackCount, coverArt, cueSheet, ripLog +end + +== 6. Filter & Select Best == + +Service -> Service: filterByQuality(parsed, quality) +note right: Match LOSSLESS/LOSSY/UNSPECIFIED\nagainst release format +Service -> Service: selectBestRelease(filtered) +note right: Highest seeder count wins + +== 7. Add to Torrent Client == + +Service -> QBit: Find(hash) +QBit --> Service: not found + +alt magnet link + Service -> QBit: AddMagnet(magnet_uri) +else torrent file + Service -> QBit: AddTorrent(file) +end +QBit --> Service: OK + +== 8. Persist Torrent & Download == + +Service -> DB: torrents.Create(torrent) +note right: Upsert on info_hash\nupdates seeders/peers +Service -> DB: torrents.GetByInfoHash(hash) +DB --> Service: savedTorrent (with DB id) + +Service -> DB: downloads.GetActiveByTorrentID(torrent_id) +DB --> Service: not found (no active download) + +Service -> DB: downloads.Create(download) +note right: state = "downloading"\nformat, quality, qbit_hash +DB --> Service: download (with DB id) + +== 9. Schedule Download Poll == + +Service -> River: Insert(PollDownloadArgs) +note right: download_id, torrent_hash\ncheck_interval = 30s\nscheduled_at = now + 30s +River --> Service: job scheduled + +== 10. Build & Return Response == + +Service -> DB: albums.GetByExternalID(external_id) +DB --> Service: dbAlbum (refreshed) +Service -> DB: downloads.GetByAlbumID(album_id) +DB --> Service: downloads (with state) +Service -> DB: artists.GetByExternalID(artist_external_id) +DB --> Service: dbArtist + +Service --> Server: MonitorAlbumResponse +note right: album: id, title, monitor_state=monitored,\n download: state, format, quality\nartist: id, name, monitor_state\nrelease: hash, format, seeders, tracks +Server --> Client: MonitorAlbumResponse + +== 11. Async: Download Polling (River Worker) == + +River -> PollWorker: Work(PollDownloadArgs) +PollWorker -> QBit: Find(hash) +QBit --> PollWorker: TorrentInfo (progress, state, path) + +alt progress < 100% + PollWorker -> River: Insert(PollDownloadArgs) + note right: Reschedule after check_interval +else progress == 100% + PollWorker -> DB: downloads.SetCompleted(id, save_path) + PollWorker -> PollWorker: scanAndHashFiles(content_path) + note right: Walk directory, identify audio files\n(.flac, .mp3, .aac, ...)\nSHA-256 hash each file + PollWorker -> DB: download_files.CreateBatch(files) + note right: file_path, file_size, file_type,\nsha256_hash, verified_at +end + +@enduml diff --git a/go.mod b/go.mod index 645b51c..de748bb 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.2 require github.com/rs/zerolog v1.35.1 require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring v1.2.3 // indirect github.com/alecthomas/atomic v0.1.0-alpha2 // indirect github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // indirect @@ -29,15 +32,27 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.2.2 // indirect github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/edsrzf/mmap-go v1.1.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -49,15 +64,29 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.2.3 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/minio/sha256-simd v1.0.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.0.6 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pion/datachannel v1.5.9 // indirect github.com/pion/dtls/v3 v3.0.3 // indirect github.com/pion/ice/v4 v4.0.2 // indirect @@ -76,6 +105,7 @@ require ( github.com/pion/webrtc/v4 v4.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect @@ -89,16 +119,24 @@ require ( github.com/riverqueue/river/rivertype v0.35.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/stretchr/testify v1.11.1 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect github.com/tidwall/btree v1.8.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/wlynxg/anet v0.0.3 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.3.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect diff --git a/go.sum b/go.sum index 9ca90de..3c07303 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,13 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT 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= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI= @@ -89,16 +95,34 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8= github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 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 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= @@ -106,8 +130,12 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn 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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -130,6 +158,9 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 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= @@ -197,6 +228,8 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY 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/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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= @@ -213,6 +246,10 @@ 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/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -220,6 +257,24 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 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= @@ -239,6 +294,10 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW 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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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= @@ -281,6 +340,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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= @@ -333,8 +394,12 @@ github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1 github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= 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/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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= @@ -359,6 +424,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -375,12 +444,18 @@ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6 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/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= @@ -388,6 +463,8 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= @@ -462,17 +539,21 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-20190916202348-b4ddaad3f8a3/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-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/indexer/service.go b/internal/indexer/service.go index 533b529..ec548bf 100644 --- a/internal/indexer/service.go +++ b/internal/indexer/service.go @@ -11,6 +11,10 @@ import ( "homelab.lan/music-agregator/internal/config" ) +type Searcher interface { + Search(query string, limit int32, indexer string) (*SearchResponse, error) +} + type IndexerService struct { indexer Indexer } diff --git a/internal/server.go b/internal/server.go index 028658f..06debb1 100644 --- a/internal/server.go +++ b/internal/server.go @@ -29,6 +29,10 @@ func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx }, nil } +func NewMusicAgregatorServerWithService(service *MusicAgregatorService) *MusicAgregatorServer { + return &MusicAgregatorServer{service: service} +} + func (s *MusicAgregatorServer) GetArtists(ctx context.Context, req *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) { return s.service.GetArtists(ctx, req) } diff --git a/internal/service.go b/internal/service.go index 6eac160..3fb4c9a 100644 --- a/internal/service.go +++ b/internal/service.go @@ -34,9 +34,9 @@ type parsedItem struct { type MusicAgregatorService struct { config config.Config metadata *metadata.MetadataService - indexer *indexer.IndexerService + indexer indexer.Searcher torrentClient torrent.TorrentClient - magnetResolver *torrentParser.MagnetResolver + magnetResolver torrentParser.Resolver riverClient *river.Client[pgx.Tx] torrents *database.TorrentRepository downloads *database.DownloadRepository @@ -83,9 +83,30 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T }, nil } +func NewMusicAgregatorServiceWithDeps( + metadata *metadata.MetadataService, + searcher indexer.Searcher, + torrentClient torrent.TorrentClient, + magnetResolver torrentParser.Resolver, + riverClient *river.Client[pgx.Tx], + db *database.DB, +) *MusicAgregatorService { + return &MusicAgregatorService{ + metadata: metadata, + indexer: searcher, + torrentClient: torrentClient, + magnetResolver: magnetResolver, + riverClient: riverClient, + torrents: database.NewTorrentRepository(db.Pool), + downloads: database.NewDownloadRepository(db.Pool), + artists: database.NewArtistRepository(db.Pool), + downloadFiles: database.NewDownloadFileRepository(db.Pool), + } +} + func (s *MusicAgregatorService) Close() { - if s.magnetResolver != nil { - s.magnetResolver.Close() + if closer, ok := s.magnetResolver.(interface{ Close() }); ok { + closer.Close() } } diff --git a/internal/tracker/magnet.go b/internal/tracker/magnet.go index 49f2139..fe7a3d5 100644 --- a/internal/tracker/magnet.go +++ b/internal/tracker/magnet.go @@ -11,6 +11,10 @@ import ( "github.com/rs/zerolog/log" ) +type Resolver interface { + Resolve(magnetURI string) ([]byte, error) +} + type MagnetResolver struct { client *torrent.Client timeout time.Duration diff --git a/internal/workers/poll_download.go b/internal/workers/poll_download.go index 9eccc8d..f776e21 100644 --- a/internal/workers/poll_download.go +++ b/internal/workers/poll_download.go @@ -106,6 +106,10 @@ func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadA } func (w *PollDownloadWorker) reschedule(ctx context.Context, args PollDownloadArgs) error { + if w.RiverClient == nil { + log.Warn().Str("download_id", args.DownloadID).Msg("no river client, cannot reschedule poll_download") + return nil + } _, err := w.RiverClient.Insert(ctx, args, &river.InsertOpts{ ScheduledAt: time.Now().Add(args.CheckInterval), }) diff --git a/test/component/fixtures_test.go b/test/component/fixtures_test.go new file mode 100644 index 0000000..1ca42b8 --- /dev/null +++ b/test/component/fixtures_test.go @@ -0,0 +1,83 @@ +package component + +import ( + metadataPb "homelab.lan/music-agregator/gen/metadata/v1" + "homelab.lan/music-agregator/internal/indexer" +) + +func newMetadataAlbum(id, title, artistID, artistName string) *metadataPb.Album { + return &metadataPb.Album{ + Id: id, + Title: title, + AlbumType: "album", + ReleaseDate: "2024-01-15", + TotalTracks: 10, + TotalDiscs: 1, + CoverUrl: "https://example.com/cover.jpg", + Artists: []*metadataPb.ArtistCredit{ + { + Artist: &metadataPb.Artist{ + Id: artistID, + Name: artistName, + }, + Role: "primary", + Position: 1, + }, + }, + Label: &metadataPb.Label{ + Id: "label-1", + Name: "Test Label", + }, + Genres: []*metadataPb.Genre{ + {Id: "genre-1", Name: "Rock"}, + {Id: "genre-2", Name: "Alternative"}, + }, + } +} + +func newMetadataTrack(id, title string, trackNum int32) *metadataPb.Track { + return &metadataPb.Track{ + Id: id, + Title: title, + DurationMs: 240000, + Isrc: "US-XYZ-24-00001", + Explicit: false, + DiscNumber: 1, + TrackNumber: trackNum, + Artists: []*metadataPb.ArtistCredit{ + { + Artist: &metadataPb.Artist{ + Id: "artist-1", + Name: "Test Artist", + }, + Role: "primary", + Position: 1, + }, + }, + } +} + +func newSearchResponse(items ...*indexer.SearchItemResult) *indexer.SearchResponse { + return &indexer.SearchResponse{ + Items: items, + } +} + +func newSearchItem(title string, seeders int, downloadLink string) *indexer.SearchItemResult { + return &indexer.SearchItemResult{ + Title: title, + DownloadLink: downloadLink, + Size: 500 * 1024 * 1024, + Tracker: "test-tracker", + Seeders: seeders, + Peers: seeders / 2, + } +} + +func newTorrentData() []byte { + return []byte("d8:announce35:http://tracker.example.com/announce4:infod6:lengthi1024e4:name9:test.flac12:piece lengthi16384e6:pieces20:01234567890123456789ee") +} + +func newTorrentDataMP3() []byte { + return []byte("d8:announce35:http://tracker.example.com/announce4:infod6:lengthi1024e4:name8:test.mp312:piece lengthi16384e6:pieces20:01234567890123456789ee") +} diff --git a/test/component/mocks_test.go b/test/component/mocks_test.go new file mode 100644 index 0000000..bc27730 --- /dev/null +++ b/test/component/mocks_test.go @@ -0,0 +1,146 @@ +package component + +import ( + "context" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + metadataPb "homelab.lan/music-agregator/gen/metadata/v1" + "homelab.lan/music-agregator/internal/indexer" + "homelab.lan/music-agregator/internal/torrent" +) + +type mockMetadataClient struct { + GetAlbumFunc func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) + GetArtistAlbumsFunc func(ctx context.Context, in *metadataPb.GetArtistAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistAlbumsResponse, error) + GetAlbumTracksFunc func(ctx context.Context, in *metadataPb.GetAlbumTracksRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumTracksResponse, error) + GetArtistFunc func(ctx context.Context, in *metadataPb.GetArtistRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistResponse, error) + SearchArtistsFunc func(ctx context.Context, in *metadataPb.SearchArtistsRequest, opts ...grpc.CallOption) (*metadataPb.SearchArtistsResponse, error) + GetTrackFunc func(ctx context.Context, in *metadataPb.GetTrackRequest, opts ...grpc.CallOption) (*metadataPb.GetTrackResponse, error) + SearchAlbumsFunc func(ctx context.Context, in *metadataPb.SearchAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.SearchAlbumsResponse, error) + SyncArtistFunc func(ctx context.Context, in *metadataPb.SyncArtistRequest, opts ...grpc.CallOption) (*metadataPb.SyncArtistResponse, error) +} + +func (m *mockMetadataClient) GetAlbum(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + if m.GetAlbumFunc != nil { + return m.GetAlbumFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +func (m *mockMetadataClient) GetArtistAlbums(ctx context.Context, in *metadataPb.GetArtistAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistAlbumsResponse, error) { + if m.GetArtistAlbumsFunc != nil { + return m.GetArtistAlbumsFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +func (m *mockMetadataClient) GetAlbumTracks(ctx context.Context, in *metadataPb.GetAlbumTracksRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumTracksResponse, error) { + if m.GetAlbumTracksFunc != nil { + return m.GetAlbumTracksFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +func (m *mockMetadataClient) GetArtist(ctx context.Context, in *metadataPb.GetArtistRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistResponse, error) { + if m.GetArtistFunc != nil { + return m.GetArtistFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +func (m *mockMetadataClient) SearchArtists(ctx context.Context, in *metadataPb.SearchArtistsRequest, opts ...grpc.CallOption) (*metadataPb.SearchArtistsResponse, error) { + if m.SearchArtistsFunc != nil { + return m.SearchArtistsFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +func (m *mockMetadataClient) GetTrack(ctx context.Context, in *metadataPb.GetTrackRequest, opts ...grpc.CallOption) (*metadataPb.GetTrackResponse, error) { + if m.GetTrackFunc != nil { + return m.GetTrackFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +func (m *mockMetadataClient) SearchAlbums(ctx context.Context, in *metadataPb.SearchAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.SearchAlbumsResponse, error) { + if m.SearchAlbumsFunc != nil { + return m.SearchAlbumsFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +func (m *mockMetadataClient) SyncArtist(ctx context.Context, in *metadataPb.SyncArtistRequest, opts ...grpc.CallOption) (*metadataPb.SyncArtistResponse, error) { + if m.SyncArtistFunc != nil { + return m.SyncArtistFunc(ctx, in, opts...) + } + return nil, status.Error(codes.Unimplemented, "not mocked") +} + +type mockTorrentClient struct { + LoginFunc func(username, password string) (string, error) + ListFunc func() ([]torrent.TorrentInfo, error) + FindFunc func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) + AddTorrentFunc func(file torrent.TorrentFile) error + AddMagnetFunc func(magnetURI string) error +} + +func (m *mockTorrentClient) Login(username, password string) (string, error) { + if m.LoginFunc != nil { + return m.LoginFunc(username, password) + } + return "", fmt.Errorf("not mocked") +} + +func (m *mockTorrentClient) List() ([]torrent.TorrentInfo, error) { + if m.ListFunc != nil { + return m.ListFunc() + } + return nil, fmt.Errorf("not mocked") +} + +func (m *mockTorrentClient) Find(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + if m.FindFunc != nil { + return m.FindFunc(opts) + } + return nil, fmt.Errorf("not mocked") +} + +func (m *mockTorrentClient) AddTorrent(file torrent.TorrentFile) error { + if m.AddTorrentFunc != nil { + return m.AddTorrentFunc(file) + } + return fmt.Errorf("not mocked") +} + +func (m *mockTorrentClient) AddMagnet(magnetURI string) error { + if m.AddMagnetFunc != nil { + return m.AddMagnetFunc(magnetURI) + } + return fmt.Errorf("not mocked") +} + +type mockSearcher struct { + SearchFunc func(query string, limit int32, indexer string) (*indexer.SearchResponse, error) +} + +func (m *mockSearcher) Search(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + if m.SearchFunc != nil { + return m.SearchFunc(query, limit, idx) + } + return nil, fmt.Errorf("not mocked") +} + +type mockResolver struct { + ResolveFunc func(magnetURI string) ([]byte, error) +} + +func (m *mockResolver) Resolve(magnetURI string) ([]byte, error) { + if m.ResolveFunc != nil { + return m.ResolveFunc(magnetURI) + } + return nil, fmt.Errorf("not mocked") +} diff --git a/test/component/monitor_album_test.go b/test/component/monitor_album_test.go new file mode 100644 index 0000000..67f4262 --- /dev/null +++ b/test/component/monitor_album_test.go @@ -0,0 +1,1133 @@ +package component + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/riverqueue/river" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + metadataPb "homelab.lan/music-agregator/gen/metadata/v1" + pb "homelab.lan/music-agregator/gen/music_agregator/v1" + "homelab.lan/music-agregator/internal/database" + "homelab.lan/music-agregator/internal/indexer" + "homelab.lan/music-agregator/internal/torrent" + "homelab.lan/music-agregator/internal/workers" +) + +func TestMonitorAlbum_HappyPath(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123&dn=test"), + newSearchItem("Test Artist - Test Album [MP3 320]", 10, "magnet:?xt=urn:btih:def456&dn=test"), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return newTorrentData(), nil + } + + suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{}, nil + } + + suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error { + return nil + } + + ctx := context.Background() + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Album) + assert.NotEmpty(t, resp.Album.Id) + assert.Equal(t, "Test Album", resp.Album.Title) + assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, resp.Album.MonitorState) + require.NotNil(t, resp.Artist) + assert.NotEmpty(t, resp.Artist.Id) + assert.Equal(t, "Test Artist", resp.Artist.Name) + assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, resp.Artist.MonitorState) + require.NotNil(t, resp.Release) + assert.NotEmpty(t, resp.Release.InfoHash) + assert.Equal(t, int32(50), resp.Release.Seeders) + + var artistMonitorState string + err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM artists WHERE external_id = $1", "artist-ext-id").Scan(&artistMonitorState) + require.NoError(t, err) + assert.Equal(t, "monitored", artistMonitorState) + + var albumMonitorState string + err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM albums WHERE external_id = $1", "test-album-ext-id").Scan(&albumMonitorState) + require.NoError(t, err) + assert.Equal(t, "monitored", albumMonitorState) + + var torrentCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&torrentCount) + require.NoError(t, err) + assert.Equal(t, 1, torrentCount) + + var downloadState string + err = suite.pool.QueryRow(ctx, "SELECT state FROM downloads").Scan(&downloadState) + require.NoError(t, err) + assert.Equal(t, "downloading", downloadState) +} + +func TestMonitorAlbum_MetadataUnavailable(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return nil, status.Error(codes.Unavailable, "connection refused") + } + + ctx := context.Background() + _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.Error(t, err) + + var count int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) + + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) + + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) + + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestMonitorAlbum_MetadataNotFound(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return nil, status.Error(codes.NotFound, "album not found") + } + + ctx := context.Background() + _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "nonexistent-album", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.Error(t, err) + + var count int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) + + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestMonitorAlbum_ArtistPersistFails(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: &metadataPb.Album{ + Id: "orphan-album-ext-id", + Title: "Orphan Album", + AlbumType: "album", + ReleaseDate: "2024-01-15", + TotalTracks: 10, + Artists: []*metadataPb.ArtistCredit{}, + }, + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Orphan Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123&dn=test"), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return newTorrentData(), nil + } + + suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{}, nil + } + + suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error { + return nil + } + + ctx := context.Background() + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "orphan-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Release) + assert.Nil(t, resp.Album) + + var count int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) + + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestMonitorAlbum_AlreadyOwned(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('owned-album-ext-id', $1, 'Owned Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, 'existing-hash', 'test-tracker', 'Owned Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID).Scan(&torrentID) + require.NoError(t, err) + + _, err = suite.pool.Exec(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash, save_path) + VALUES ($1, $2, 'QUALITY_LOSSLESS', '16-44', 'completed', 'existing-hash', '/music/owned') + `, torrentID, albumID) + require.NoError(t, err) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("owned-album-ext-id", "Owned Album", "artist-ext-id", "Test Artist"), + }, nil + } + + var indexerCalled bool + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + indexerCalled = true + return newSearchResponse(), nil + } + + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "owned-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Album) + assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, resp.Album.MonitorState) + require.NotNil(t, resp.Album.Download) + assert.Equal(t, "completed", resp.Album.Download.State) + assert.Nil(t, resp.Release) + assert.False(t, indexerCalled) + + var downloadCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) + require.NoError(t, err) + assert.Equal(t, 1, downloadCount) +} + +func TestMonitorAlbum_IndexerDown(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return nil, assert.AnError + } + + ctx := context.Background() + _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.Error(t, err) + + var artistMonitorState string + err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM artists WHERE external_id = $1", "artist-ext-id").Scan(&artistMonitorState) + require.NoError(t, err) + assert.Equal(t, "monitored", artistMonitorState) + + var albumMonitorState string + err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM albums WHERE external_id = $1", "test-album-ext-id").Scan(&albumMonitorState) + require.NoError(t, err) + assert.Equal(t, "monitored", albumMonitorState) +} + +func TestMonitorAlbum_IndexerNoResults(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse(), nil + } + + ctx := context.Background() + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Album) + require.NotNil(t, resp.Artist) + assert.Nil(t, resp.Release) + + var count int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) + + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestMonitorAlbum_AllSeedersZero(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Album FLAC 1", 0, "magnet:?xt=urn:btih:abc1"), + newSearchItem("Test Album FLAC 2", 0, "magnet:?xt=urn:btih:abc2"), + newSearchItem("Test Album FLAC 3", 0, "magnet:?xt=urn:btih:abc3"), + ), nil + } + + ctx := context.Background() + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Album) + require.NotNil(t, resp.Artist) + assert.Nil(t, resp.Release) +} + +func TestMonitorAlbum_AllMagnetsFail(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Album 1", 50, "magnet:?xt=urn:btih:abc1"), + newSearchItem("Test Album 2", 30, "magnet:?xt=urn:btih:abc2"), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return nil, assert.AnError + } + + ctx := context.Background() + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Album) + require.NotNil(t, resp.Artist) + assert.Nil(t, resp.Release) +} + +func TestMonitorAlbum_NoQualityMatch(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Album MP3 320", 50, "magnet:?xt=urn:btih:abc1"), + newSearchItem("Test Album MP3 V0", 30, "magnet:?xt=urn:btih:abc2"), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return newTorrentDataMP3(), nil + } + + ctx := context.Background() + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Album) + require.NotNil(t, resp.Artist) + assert.Nil(t, resp.Release) +} + +func TestMonitorAlbum_QBitDown(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return newTorrentData(), nil + } + + suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return nil, assert.AnError + } + + suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error { + return assert.AnError + } + + ctx := context.Background() + _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.Error(t, err) + + var artistCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&artistCount) + require.NoError(t, err) + assert.Equal(t, 1, artistCount) + + var albumCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&albumCount) + require.NoError(t, err) + assert.Equal(t, 1, albumCount) + + var torrentCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&torrentCount) + require.NoError(t, err) + assert.Equal(t, 0, torrentCount) + + var downloadCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) + require.NoError(t, err) + assert.Equal(t, 0, downloadCount) +} + +func TestMonitorAlbum_TorrentAlreadyExists(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'unmonitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + torrentHash := "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70" + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, $2, 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID, torrentHash).Scan(&torrentID) + require.NoError(t, err) + + _, err = suite.pool.Exec(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'completed', $3) + `, torrentID, albumID, torrentHash) + require.NoError(t, err) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Album FLAC", 50, "magnet:?xt=urn:btih:"+torrentHash), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return newTorrentData(), nil + } + + suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{{State: "stalledUP"}}, nil + } + + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Release) + + var downloadCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) + require.NoError(t, err) + assert.Equal(t, 1, downloadCount) +} + +func TestMonitorAlbum_AddMagnetFails(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Album FLAC", 50, "magnet:?xt=urn:btih:abc123"), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return newTorrentData(), nil + } + + suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{}, nil + } + + suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error { + return assert.AnError + } + + ctx := context.Background() + _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.Error(t, err) + + var albumCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&albumCount) + require.NoError(t, err) + assert.Equal(t, 1, albumCount) + + var downloadCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) + require.NoError(t, err) + assert.Equal(t, 0, downloadCount) +} + +func TestMonitorAlbum_DuplicateDownloadSkipped(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'unmonitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + torrentHash := "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70" + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, $2, 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID, torrentHash).Scan(&torrentID) + require.NoError(t, err) + + _, err = suite.pool.Exec(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'downloading', $3) + `, torrentID, albumID, torrentHash) + require.NoError(t, err) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + return newSearchResponse( + newSearchItem("Test Album FLAC", 50, "magnet:?xt=urn:btih:"+torrentHash), + ), nil + } + + suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { + return newTorrentData(), nil + } + + suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{{State: "downloading"}}, nil + } + + resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + require.NotNil(t, resp.Release) + + var downloadCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) + require.NoError(t, err) + assert.Equal(t, 1, downloadCount) +} + +func TestMonitorAlbum_SearchQueryFormat(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { + return &metadataPb.GetAlbumResponse{ + Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), + }, nil + } + + var capturedQuery string + suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { + capturedQuery = query + return newSearchResponse(), nil + } + + ctx := context.Background() + _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ + AlbumId: "test-album-ext-id", + Quality: pb.QualityType_QUALITY_LOSSLESS, + }) + + require.NoError(t, err) + assert.Equal(t, "Test Artist Test Album", capturedQuery) +} + +func TestPollWorker_QBitUnreachable(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, 'poll-hash-123', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID).Scan(&torrentID) + require.NoError(t, err) + + var downloadID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'poll-hash-123') + RETURNING id + `, torrentID, albumID).Scan(&downloadID) + require.NoError(t, err) + + mockTorrent := &mockTorrentClient{ + FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return nil, assert.AnError + }, + } + + worker := &workers.PollDownloadWorker{ + TorrentClient: mockTorrent, + Downloads: database.NewDownloadRepository(suite.pool), + DownloadFiles: database.NewDownloadFileRepository(suite.pool), + RiverClient: nil, + } + + job := &river.Job[workers.PollDownloadArgs]{ + Args: workers.PollDownloadArgs{ + DownloadID: downloadID, + TorrentHash: "poll-hash-123", + }, + } + + err = worker.Work(ctx, job) + require.NoError(t, err) + + var state string + err = suite.pool.QueryRow(ctx, "SELECT state FROM downloads WHERE id = $1", downloadID).Scan(&state) + require.NoError(t, err) + assert.Equal(t, "downloading", state) +} + +func TestPollWorker_TorrentDisappeared(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, 'disappeared-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID).Scan(&torrentID) + require.NoError(t, err) + + var downloadID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'disappeared-hash') + RETURNING id + `, torrentID, albumID).Scan(&downloadID) + require.NoError(t, err) + + mockTorrent := &mockTorrentClient{ + FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{}, nil + }, + } + + worker := &workers.PollDownloadWorker{ + TorrentClient: mockTorrent, + Downloads: database.NewDownloadRepository(suite.pool), + DownloadFiles: database.NewDownloadFileRepository(suite.pool), + RiverClient: nil, + } + + job := &river.Job[workers.PollDownloadArgs]{ + Args: workers.PollDownloadArgs{ + DownloadID: downloadID, + TorrentHash: "disappeared-hash", + }, + } + + err = worker.Work(ctx, job) + require.NoError(t, err) + + var state, errorMsg string + err = suite.pool.QueryRow(ctx, "SELECT state, error_message FROM downloads WHERE id = $1", downloadID).Scan(&state, &errorMsg) + require.NoError(t, err) + assert.Equal(t, "failed", state) + assert.Equal(t, "torrent not found in client", errorMsg) +} + +func TestPollWorker_TorrentError(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, 'error-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID).Scan(&torrentID) + require.NoError(t, err) + + var downloadID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'error-hash') + RETURNING id + `, torrentID, albumID).Scan(&downloadID) + require.NoError(t, err) + + mockTorrent := &mockTorrentClient{ + FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{{State: "error"}}, nil + }, + } + + worker := &workers.PollDownloadWorker{ + TorrentClient: mockTorrent, + Downloads: database.NewDownloadRepository(suite.pool), + DownloadFiles: database.NewDownloadFileRepository(suite.pool), + RiverClient: nil, + } + + job := &river.Job[workers.PollDownloadArgs]{ + Args: workers.PollDownloadArgs{ + DownloadID: downloadID, + TorrentHash: "error-hash", + }, + } + + err = worker.Work(ctx, job) + require.NoError(t, err) + + var state, errorMsg string + err = suite.pool.QueryRow(ctx, "SELECT state, error_message FROM downloads WHERE id = $1", downloadID).Scan(&state, &errorMsg) + require.NoError(t, err) + assert.Equal(t, "failed", state) + assert.Equal(t, "torrent error state", errorMsg) +} + +func TestPollWorker_CompletedSuccess(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + tempDir := t.TempDir() + flacFile1 := filepath.Join(tempDir, "01-track.flac") + flacFile2 := filepath.Join(tempDir, "02-track.flac") + require.NoError(t, os.WriteFile(flacFile1, []byte("fake flac data 1"), 0644)) + require.NoError(t, os.WriteFile(flacFile2, []byte("fake flac data 2"), 0644)) + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, 'completed-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID).Scan(&torrentID) + require.NoError(t, err) + + var downloadID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'completed-hash') + RETURNING id + `, torrentID, albumID).Scan(&downloadID) + require.NoError(t, err) + + mockTorrent := &mockTorrentClient{ + FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{{ + Progress: 1.0, + SavePath: "/downloads", + ContentPath: tempDir, + }}, nil + }, + } + + worker := &workers.PollDownloadWorker{ + TorrentClient: mockTorrent, + Downloads: database.NewDownloadRepository(suite.pool), + DownloadFiles: database.NewDownloadFileRepository(suite.pool), + RiverClient: nil, + } + + job := &river.Job[workers.PollDownloadArgs]{ + Args: workers.PollDownloadArgs{ + DownloadID: downloadID, + TorrentHash: "completed-hash", + }, + } + + err = worker.Work(ctx, job) + require.NoError(t, err) + + var state string + var savePath *string + err = suite.pool.QueryRow(ctx, "SELECT state, save_path FROM downloads WHERE id = $1", downloadID).Scan(&state, &savePath) + require.NoError(t, err) + assert.Equal(t, "completed", state) + require.NotNil(t, savePath) + assert.Equal(t, "/downloads", *savePath) + + var fileCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM download_files WHERE download_id = $1", downloadID).Scan(&fileCount) + require.NoError(t, err) + assert.Equal(t, 2, fileCount) + + var fileTypes []string + rows, err := suite.pool.Query(ctx, "SELECT file_type FROM download_files WHERE download_id = $1", downloadID) + require.NoError(t, err) + defer rows.Close() + for rows.Next() { + var ft string + require.NoError(t, rows.Scan(&ft)) + fileTypes = append(fileTypes, ft) + } + assert.Contains(t, fileTypes, "flac") +} + +func TestPollWorker_FileScanFails(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, 'scan-fail-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID).Scan(&torrentID) + require.NoError(t, err) + + var downloadID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'scan-fail-hash') + RETURNING id + `, torrentID, albumID).Scan(&downloadID) + require.NoError(t, err) + + mockTorrent := &mockTorrentClient{ + FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { + return []torrent.TorrentInfo{{ + Progress: 1.0, + SavePath: "/downloads", + ContentPath: "/nonexistent/path/that/does/not/exist", + }}, nil + }, + } + + worker := &workers.PollDownloadWorker{ + TorrentClient: mockTorrent, + Downloads: database.NewDownloadRepository(suite.pool), + DownloadFiles: database.NewDownloadFileRepository(suite.pool), + RiverClient: nil, + } + + job := &river.Job[workers.PollDownloadArgs]{ + Args: workers.PollDownloadArgs{ + DownloadID: downloadID, + TorrentHash: "scan-fail-hash", + }, + } + + err = worker.Work(ctx, job) + require.NoError(t, err) + + var state string + err = suite.pool.QueryRow(ctx, "SELECT state FROM downloads WHERE id = $1", downloadID).Scan(&state) + require.NoError(t, err) + assert.Equal(t, "completed", state) + + var fileCount int + err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM download_files WHERE download_id = $1", downloadID).Scan(&fileCount) + require.NoError(t, err) + assert.Equal(t, 0, fileCount) +} + +func TestRecoverOrphaned_FindsActiveDownloads(t *testing.T) { + suite := setupSuite(t) + cleanTables(t, suite.pool) + + ctx := context.Background() + + var artistID string + err := suite.pool.QueryRow(ctx, ` + INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') + RETURNING id + `).Scan(&artistID) + require.NoError(t, err) + + var albumID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) + VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') + RETURNING id + `, artistID).Scan(&albumID) + require.NoError(t, err) + + var torrentID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) + VALUES ($1, 'orphan-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) + RETURNING id + `, albumID).Scan(&torrentID) + require.NoError(t, err) + + var downloadID string + err = suite.pool.QueryRow(ctx, ` + INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) + VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'orphan-hash') + RETURNING id + `, torrentID, albumID).Scan(&downloadID) + require.NoError(t, err) + + downloads := database.NewDownloadRepository(suite.pool) + active, err := downloads.GetActive(ctx) + require.NoError(t, err) + require.Len(t, active, 1) + assert.Equal(t, downloadID, active[0].ID) + assert.Equal(t, "orphan-hash", active[0].QbitHash) + assert.Equal(t, "downloading", active[0].State) +} diff --git a/test/component/setup_test.go b/test/component/setup_test.go new file mode 100644 index 0000000..a6183af --- /dev/null +++ b/test/component/setup_test.go @@ -0,0 +1,174 @@ +package component + +import ( + "context" + "net" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + pb "homelab.lan/music-agregator/gen/music_agregator/v1" + "homelab.lan/music-agregator/internal" + "homelab.lan/music-agregator/internal/database" + "homelab.lan/music-agregator/internal/metadata" +) + +const bufSize = 1024 * 1024 + +type testMocks struct { + metadata *mockMetadataClient + torrent *mockTorrentClient + indexer *mockSearcher + magnet *mockResolver +} + +type testSuite struct { + db *database.DB + grpcConn *grpc.ClientConn + client pb.MusicAgregatorServiceClient + pool *pgxpool.Pool + mocks *testMocks +} + +func setupSuite(t *testing.T) *testSuite { + ctx := context.Background() + + schemaPath := getSchemaPath(t) + schemaSQL, err := os.ReadFile(schemaPath) + require.NoError(t, err, "failed to read schema file") + + pgContainer, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("music_agregator_test"), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + postgres.WithInitScripts(), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + require.NoError(t, err, "failed to start postgres container") + + t.Cleanup(func() { + if err := pgContainer.Terminate(ctx); err != nil { + t.Logf("failed to terminate postgres container: %v", err) + } + }) + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err, "failed to get connection string") + + pool, err := pgxpool.New(ctx, connStr) + require.NoError(t, err, "failed to create pgxpool") + + t.Cleanup(func() { + pool.Close() + }) + + _, err = pool.Exec(ctx, string(schemaSQL)) + require.NoError(t, err, "failed to apply schema") + + db := &database.DB{Pool: pool} + + mocks := &testMocks{ + metadata: &mockMetadataClient{}, + torrent: &mockTorrentClient{}, + indexer: &mockSearcher{}, + magnet: &mockResolver{}, + } + + metadataSvc := metadata.NewMetadataService(mocks.metadata, db) + + service := internal.NewMusicAgregatorServiceWithDeps( + metadataSvc, + mocks.indexer, + mocks.torrent, + mocks.magnet, + nil, + db, + ) + + server := internal.NewMusicAgregatorServerWithService(service) + + lis := bufconn.Listen(bufSize) + grpcServer := grpc.NewServer() + server.Register(grpcServer) + + go func() { + if err := grpcServer.Serve(lis); err != nil { + t.Logf("grpc server error: %v", err) + } + }() + + t.Cleanup(func() { + grpcServer.GracefulStop() + }) + + conn, err := grpc.NewClient( + "passthrough://bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return lis.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err, "failed to create grpc client connection") + + t.Cleanup(func() { + conn.Close() + }) + + client := pb.NewMusicAgregatorServiceClient(conn) + + return &testSuite{ + db: db, + grpcConn: conn, + client: client, + pool: pool, + mocks: mocks, + } +} + +func getSchemaPath(t *testing.T) string { + _, currentFile, _, ok := runtime.Caller(0) + require.True(t, ok, "failed to get current file path") + + testDir := filepath.Dir(currentFile) + schemaPath := filepath.Join(testDir, "..", "..", "..", "containers", "database", "music-agregator", "002_schema.sql") + + if _, err := os.Stat(schemaPath); os.IsNotExist(err) { + schemaPath = filepath.Join(testDir, "..", "..", "containers", "database", "music-agregator", "002_schema.sql") + } + + return schemaPath +} + +func cleanTables(t *testing.T, pool *pgxpool.Pool) { + ctx := context.Background() + + tables := []string{ + "download_files", + "downloads", + "torrents", + "tracks", + "albums", + "artists", + } + + for _, table := range tables { + _, err := pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE") + require.NoError(t, err, "failed to truncate table %s", table) + } +}