Compare commits
9 Commits
60c94935b2
...
758a4b909a
| Author | SHA1 | Date | |
|---|---|---|---|
| 758a4b909a | |||
| 31ec3f9826 | |||
| 6f31698006 | |||
| 3ce6e23421 | |||
| cca404bcc0 | |||
| 5257ed0f1b | |||
| 8c60fe5e35 | |||
| e61e58be72 | |||
| e49cc25372 |
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Album
|
||||||
|
type: grpc
|
||||||
|
seq: 9
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.v1.MusicAgregatorService/GetAlbum
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"album_id": "1d51e4a7-1a8b-4160-bd08-1aee658a991a"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Artists
|
||||||
|
type: grpc
|
||||||
|
seq: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.v1.MusicAgregatorService/GetArtists
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -113,6 +113,8 @@ func setupRiver(ctx context.Context, cfg config.Config, db *database.DB) *riverS
|
|||||||
|
|
||||||
log.Info().Msg("River queue started")
|
log.Info().Msg("River queue started")
|
||||||
|
|
||||||
|
pollWorker.RecoverOrphanedDownloads(ctx)
|
||||||
|
|
||||||
return &riverSetup{
|
return &riverSetup{
|
||||||
client: riverClient,
|
client: riverClient,
|
||||||
cacheRefreshWorker: cacheWorker,
|
cacheRefreshWorker: cacheWorker,
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
-- Music Aggregator Database Schema
|
|
||||||
-- Based on docs/erd.puml
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- CONFIGURATION
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TABLE quality_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
cutoff INT NOT NULL DEFAULT 0,
|
|
||||||
items JSONB NOT NULL DEFAULT '[]',
|
|
||||||
upgrade_allowed BOOLEAN NOT NULL DEFAULT true
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE metadata_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
primary_album_types JSONB NOT NULL DEFAULT '["Album", "EP"]',
|
|
||||||
secondary_album_types JSONB NOT NULL DEFAULT '[]',
|
|
||||||
release_statuses JSONB NOT NULL DEFAULT '["Official"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE root_folders (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL UNIQUE,
|
|
||||||
default_quality_profile_id UUID REFERENCES quality_profiles(id),
|
|
||||||
default_metadata_profile_id UUID REFERENCES metadata_profiles(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE indexers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
implementation TEXT NOT NULL,
|
|
||||||
settings JSONB NOT NULL DEFAULT '{}',
|
|
||||||
enable_rss BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
enable_search BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
priority INT NOT NULL DEFAULT 25
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE download_clients (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
implementation TEXT NOT NULL,
|
|
||||||
settings JSONB NOT NULL DEFAULT '{}',
|
|
||||||
protocol TEXT NOT NULL DEFAULT 'torrent',
|
|
||||||
priority INT NOT NULL DEFAULT 1,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- CORE MUSIC ENTITIES
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TABLE artist_metadata (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
foreign_artist_id TEXT UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sort_name TEXT,
|
|
||||||
disambiguation TEXT,
|
|
||||||
artist_type TEXT,
|
|
||||||
status TEXT,
|
|
||||||
overview TEXT,
|
|
||||||
images JSONB NOT NULL DEFAULT '[]',
|
|
||||||
links JSONB NOT NULL DEFAULT '[]',
|
|
||||||
genres JSONB NOT NULL DEFAULT '[]',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE artists (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
|
|
||||||
quality_profile_id UUID REFERENCES quality_profiles(id),
|
|
||||||
metadata_profile_id UUID REFERENCES metadata_profiles(id),
|
|
||||||
root_folder_id UUID REFERENCES root_folders(id),
|
|
||||||
path TEXT,
|
|
||||||
monitored BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
monitor_new_items TEXT NOT NULL DEFAULT 'all',
|
|
||||||
last_info_sync TIMESTAMPTZ,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE albums (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
|
|
||||||
foreign_album_id TEXT UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
clean_title TEXT,
|
|
||||||
disambiguation TEXT,
|
|
||||||
overview TEXT,
|
|
||||||
album_type TEXT,
|
|
||||||
release_date DATE,
|
|
||||||
images JSONB NOT NULL DEFAULT '[]',
|
|
||||||
genres JSONB NOT NULL DEFAULT '[]',
|
|
||||||
monitored BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE album_releases (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
foreign_release_id TEXT UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
status TEXT,
|
|
||||||
duration_ms INT,
|
|
||||||
release_date DATE,
|
|
||||||
country TEXT[],
|
|
||||||
label TEXT[],
|
|
||||||
format TEXT,
|
|
||||||
track_count INT,
|
|
||||||
monitored BOOLEAN NOT NULL DEFAULT true
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE track_files (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
relative_path TEXT NOT NULL,
|
|
||||||
size BIGINT NOT NULL DEFAULT 0,
|
|
||||||
file_hash TEXT,
|
|
||||||
audio_hash TEXT,
|
|
||||||
quality JSONB NOT NULL DEFAULT '{}',
|
|
||||||
media_info JSONB NOT NULL DEFAULT '{}',
|
|
||||||
scene_name TEXT,
|
|
||||||
release_group TEXT,
|
|
||||||
date_added TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tracks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_release_id UUID NOT NULL REFERENCES album_releases(id) ON DELETE CASCADE,
|
|
||||||
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
|
|
||||||
track_file_id UUID REFERENCES track_files(id) ON DELETE SET NULL,
|
|
||||||
foreign_track_id TEXT UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
track_number INT NOT NULL DEFAULT 1,
|
|
||||||
disc_number INT NOT NULL DEFAULT 1,
|
|
||||||
duration_ms INT,
|
|
||||||
explicit BOOLEAN NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- DOWNLOAD TRACKING
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TABLE wanted_albums (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_id UUID NOT NULL UNIQUE REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
priority INT NOT NULL DEFAULT 0,
|
|
||||||
search_count INT NOT NULL DEFAULT 0,
|
|
||||||
last_searched_at TIMESTAMPTZ,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE download_queue (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
artist_id UUID REFERENCES artists(id) ON DELETE SET NULL,
|
|
||||||
album_id UUID REFERENCES albums(id) ON DELETE SET NULL,
|
|
||||||
download_id TEXT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
size BIGINT NOT NULL DEFAULT 0,
|
|
||||||
size_left BIGINT NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
progress REAL NOT NULL DEFAULT 0.0,
|
|
||||||
error_message TEXT,
|
|
||||||
protocol TEXT NOT NULL DEFAULT 'torrent',
|
|
||||||
indexer TEXT,
|
|
||||||
download_client TEXT,
|
|
||||||
torrent_hash TEXT,
|
|
||||||
output_path TEXT,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
completed_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE blocklist (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
|
||||||
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
source_title TEXT NOT NULL,
|
|
||||||
quality JSONB NOT NULL DEFAULT '{}',
|
|
||||||
size BIGINT NOT NULL DEFAULT 0,
|
|
||||||
protocol TEXT,
|
|
||||||
indexer TEXT,
|
|
||||||
message TEXT,
|
|
||||||
torrent_hash TEXT,
|
|
||||||
date TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- INDEXES
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE INDEX idx_artist_metadata_name ON artist_metadata(name);
|
|
||||||
CREATE INDEX idx_artist_metadata_foreign_id ON artist_metadata(foreign_artist_id);
|
|
||||||
CREATE INDEX idx_albums_artist ON albums(artist_metadata_id);
|
|
||||||
CREATE INDEX idx_albums_foreign_id ON albums(foreign_album_id);
|
|
||||||
CREATE INDEX idx_albums_release_date ON albums(release_date);
|
|
||||||
CREATE INDEX idx_album_releases_album ON album_releases(album_id);
|
|
||||||
CREATE INDEX idx_tracks_release ON tracks(album_release_id);
|
|
||||||
CREATE INDEX idx_tracks_artist ON tracks(artist_metadata_id);
|
|
||||||
CREATE INDEX idx_track_files_album ON track_files(album_id);
|
|
||||||
CREATE INDEX idx_track_files_hash ON track_files(file_hash);
|
|
||||||
CREATE INDEX idx_track_files_audio_hash ON track_files(audio_hash);
|
|
||||||
CREATE INDEX idx_wanted_albums_priority ON wanted_albums(priority DESC);
|
|
||||||
CREATE INDEX idx_download_queue_status ON download_queue(status);
|
|
||||||
CREATE INDEX idx_download_queue_album ON download_queue(album_id);
|
|
||||||
CREATE INDEX idx_blocklist_artist ON blocklist(artist_id);
|
|
||||||
CREATE INDEX idx_blocklist_torrent ON blocklist(torrent_hash);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- DEFAULT DATA
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
INSERT INTO quality_profiles (name, cutoff, items, upgrade_allowed) VALUES
|
|
||||||
('Any', 0, '[]', true),
|
|
||||||
('Lossless', 1, '[{"quality": "FLAC", "allowed": true}, {"quality": "ALAC", "allowed": true}]', true),
|
|
||||||
('Standard', 2, '[{"quality": "MP3-320", "allowed": true}, {"quality": "MP3-VBR-V0", "allowed": true}]', true);
|
|
||||||
|
|
||||||
INSERT INTO metadata_profiles (name, primary_album_types, secondary_album_types, release_statuses) VALUES
|
|
||||||
('Standard', '["Album", "EP"]', '[]', '["Official"]'),
|
|
||||||
('All', '["Album", "EP", "Single", "Broadcast", "Other"]', '["Compilation", "Soundtrack", "Spokenword", "Interview", "Audiobook", "Live", "Remix", "DJ-mix", "Mixtape/Street", "Demo"]', '["Official", "Promotional", "Bootleg"]');
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -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
|
||||||
@@ -5,6 +5,9 @@ go 1.26.2
|
|||||||
require github.com/rs/zerolog v1.35.1
|
require github.com/rs/zerolog v1.35.1
|
||||||
|
|
||||||
require (
|
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/RoaringBitmap/roaring v1.2.3 // indirect
|
||||||
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||||
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // 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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.2.2 // indirect
|
github.com/bits-and-blooms/bitset v1.2.2 // indirect
|
||||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // 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 v1.1.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.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/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/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/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/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
|
||||||
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/btree v1.1.2 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.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/klauspost/cpuid/v2 v2.2.3 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // 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/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/minio/sha256-simd v1.0.0 // 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/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/mschoch/smat v0.2.0 // indirect
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.6 // indirect
|
github.com/multiformats/go-varint v0.0.6 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/datachannel v1.5.9 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.3 // indirect
|
github.com/pion/dtls/v3 v3.0.3 // indirect
|
||||||
github.com/pion/ice/v4 v4.0.2 // 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/pion/webrtc/v4 v4.0.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // 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/riverqueue/river/rivertype v0.35.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // 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/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // 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/btree v1.8.1 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.2.0 // indirect
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // 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/wlynxg/anet v0.0.3 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.etcd.io/bbolt v1.3.6 // indirect
|
go.etcd.io/bbolt v1.3.6 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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 v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
|
|||||||
@@ -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=
|
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/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
|
||||||
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
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/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/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.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||||
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
|
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-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 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
|
||||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
|
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 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
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.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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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/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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
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-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/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/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 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
|
||||||
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
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.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
|
||||||
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
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=
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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/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.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
github.com/gogo/protobuf v1.2.0/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/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/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/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.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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
|
github.com/klauspost/cpuid/v2 v2.2.3 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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=
|
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/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 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.7.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/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/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/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
github.com/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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.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 v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
github.com/prometheus/client_golang v1.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 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
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/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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
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-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
github.com/smartystreets/goconvey v0.0.0-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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
||||||
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
||||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
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.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.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
github.com/tinylib/msgp v1.1.2/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.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||||
github.com/willf/bitset v1.1.10/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 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
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.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
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 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||||
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
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.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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
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-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-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-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-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-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-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-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-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-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-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-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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -9,20 +9,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
ID string
|
ID string
|
||||||
ExternalID string
|
ExternalID string
|
||||||
ArtistID string
|
ArtistID string
|
||||||
Title string
|
Title string
|
||||||
AlbumType string
|
AlbumType string
|
||||||
ReleaseDate *time.Time
|
ReleaseDate *time.Time
|
||||||
TotalTracks int
|
TotalTracks int
|
||||||
TotalDiscs int
|
TotalDiscs int
|
||||||
Label string
|
Label string
|
||||||
Genres []string
|
Genres []string
|
||||||
CoverURL string
|
CoverURL string
|
||||||
IsMonitored bool
|
MonitorState MonitorState
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumRepository struct {
|
type AlbumRepository struct {
|
||||||
@@ -35,7 +35,7 @@ func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
|
|||||||
|
|
||||||
func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
|
func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`INSERT INTO albums (external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored)
|
`INSERT INTO albums (external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
ON CONFLICT (external_id) DO UPDATE SET
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
@@ -46,8 +46,13 @@ func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
|
|||||||
label = EXCLUDED.label,
|
label = EXCLUDED.label,
|
||||||
genres = EXCLUDED.genres,
|
genres = EXCLUDED.genres,
|
||||||
cover_url = EXCLUDED.cover_url,
|
cover_url = EXCLUDED.cover_url,
|
||||||
|
monitor_state = CASE
|
||||||
|
WHEN albums.monitor_state = 'excluded' THEN albums.monitor_state
|
||||||
|
WHEN albums.monitor_state = 'monitored' THEN albums.monitor_state
|
||||||
|
ELSE EXCLUDED.monitor_state
|
||||||
|
END,
|
||||||
updated_at = NOW()`,
|
updated_at = NOW()`,
|
||||||
a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.IsMonitored,
|
a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.MonitorState,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating album: %w", err)
|
return fmt.Errorf("creating album: %w", err)
|
||||||
@@ -58,9 +63,9 @@ func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
|
|||||||
func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string) (*Album, error) {
|
func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string) (*Album, error) {
|
||||||
a := &Album{}
|
a := &Album{}
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
FROM albums WHERE external_id = $1`, externalID,
|
FROM albums WHERE external_id = $1`, externalID,
|
||||||
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt)
|
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting album: %w", err)
|
return nil, fmt.Errorf("getting album: %w", err)
|
||||||
}
|
}
|
||||||
@@ -70,9 +75,9 @@ func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string
|
|||||||
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error) {
|
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error) {
|
||||||
a := &Album{}
|
a := &Album{}
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
FROM albums WHERE id = $1`, id,
|
FROM albums WHERE id = $1`, id,
|
||||||
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt)
|
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting album: %w", err)
|
return nil, fmt.Errorf("getting album: %w", err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error
|
|||||||
|
|
||||||
func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([]*Album, error) {
|
func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([]*Album, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
FROM albums WHERE artist_id = $1 ORDER BY release_date DESC`, artistID,
|
FROM albums WHERE artist_id = $1 ORDER BY release_date DESC`, artistID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,7 +97,7 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([
|
|||||||
var albums []*Album
|
var albums []*Album
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
a := &Album{}
|
a := &Album{}
|
||||||
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scanning album: %w", err)
|
return nil, fmt.Errorf("scanning album: %w", err)
|
||||||
}
|
}
|
||||||
albums = append(albums, a)
|
albums = append(albums, a)
|
||||||
@@ -102,8 +107,8 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([
|
|||||||
|
|
||||||
func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
|
func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
FROM albums WHERE is_monitored = TRUE ORDER BY release_date DESC`,
|
FROM albums WHERE monitor_state = 'monitored' ORDER BY release_date DESC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing monitored albums: %w", err)
|
return nil, fmt.Errorf("listing monitored albums: %w", err)
|
||||||
@@ -113,7 +118,7 @@ func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
|
|||||||
var albums []*Album
|
var albums []*Album
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
a := &Album{}
|
a := &Album{}
|
||||||
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scanning album: %w", err)
|
return nil, fmt.Errorf("scanning album: %w", err)
|
||||||
}
|
}
|
||||||
albums = append(albums, a)
|
albums = append(albums, a)
|
||||||
@@ -121,12 +126,12 @@ func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
|
|||||||
return albums, nil
|
return albums, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AlbumRepository) SetMonitored(ctx context.Context, id string, monitored bool) error {
|
func (r *AlbumRepository) SetMonitorState(ctx context.Context, id string, state MonitorState) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`UPDATE albums SET is_monitored = $1, updated_at = NOW() WHERE id = $2`, monitored, id,
|
`UPDATE albums SET monitor_state = $1, updated_at = NOW() WHERE id = $2`, state, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("updating monitored state: %w", err)
|
return fmt.Errorf("updating monitor state: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ID string
|
ID string
|
||||||
ExternalID string
|
ExternalID string
|
||||||
Name string
|
Name string
|
||||||
ArtistType string
|
ArtistType string
|
||||||
Country string
|
Country string
|
||||||
Genres []string
|
Genres []string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
CreatedAt time.Time
|
MonitorState MonitorState
|
||||||
UpdatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistRepository struct {
|
type ArtistRepository struct {
|
||||||
@@ -30,17 +31,22 @@ func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository {
|
|||||||
|
|
||||||
func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error {
|
func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`INSERT INTO artists (external_id, name, artist_type, country, genres, image_url)
|
`INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (external_id) DO UPDATE SET
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
artist_type = EXCLUDED.artist_type,
|
artist_type = EXCLUDED.artist_type,
|
||||||
country = EXCLUDED.country,
|
country = EXCLUDED.country,
|
||||||
genres = EXCLUDED.genres,
|
genres = EXCLUDED.genres,
|
||||||
image_url = EXCLUDED.image_url,
|
image_url = EXCLUDED.image_url,
|
||||||
|
monitor_state = CASE
|
||||||
|
WHEN artists.monitor_state = 'excluded' THEN artists.monitor_state
|
||||||
|
WHEN artists.monitor_state = 'monitored' THEN artists.monitor_state
|
||||||
|
ELSE EXCLUDED.monitor_state
|
||||||
|
END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING id, created_at, updated_at`,
|
RETURNING id, created_at, updated_at`,
|
||||||
a.ExternalID, a.Name, a.ArtistType, a.Country, a.Genres, a.ImageURL,
|
a.ExternalID, a.Name, a.ArtistType, a.Country, a.Genres, a.ImageURL, a.MonitorState,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating artist: %w", err)
|
return fmt.Errorf("creating artist: %w", err)
|
||||||
@@ -51,21 +57,42 @@ func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error {
|
|||||||
func (r *ArtistRepository) GetByExternalID(ctx context.Context, externalID string) (*Artist, error) {
|
func (r *ArtistRepository) GetByExternalID(ctx context.Context, externalID string) (*Artist, error) {
|
||||||
a := &Artist{}
|
a := &Artist{}
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, external_id, name, artist_type, country, genres, image_url, created_at, updated_at
|
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
|
||||||
FROM artists WHERE external_id = $1`, externalID,
|
FROM artists WHERE external_id = $1`, externalID,
|
||||||
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.CreatedAt, &a.UpdatedAt)
|
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting artist: %w", err)
|
return nil, fmt.Errorf("getting artist: %w", err)
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ArtistRepository) GetAll(ctx context.Context) ([]*Artist, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
|
||||||
|
FROM artists ORDER BY name ASC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing artists: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var artists []*Artist
|
||||||
|
for rows.Next() {
|
||||||
|
a := &Artist{}
|
||||||
|
if err := rows.Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning artist: %w", err)
|
||||||
|
}
|
||||||
|
artists = append(artists, a)
|
||||||
|
}
|
||||||
|
return artists, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ArtistRepository) GetByID(ctx context.Context, id string) (*Artist, error) {
|
func (r *ArtistRepository) GetByID(ctx context.Context, id string) (*Artist, error) {
|
||||||
a := &Artist{}
|
a := &Artist{}
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, external_id, name, artist_type, country, genres, image_url, created_at, updated_at
|
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
|
||||||
FROM artists WHERE id = $1`, id,
|
FROM artists WHERE id = $1`, id,
|
||||||
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.CreatedAt, &a.UpdatedAt)
|
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting artist: %w", err)
|
return nil, fmt.Errorf("getting artist: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MonitorState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Monitored MonitorState = "monitored"
|
||||||
|
Unmonitored MonitorState = "unmonitored"
|
||||||
|
Excluded MonitorState = "excluded"
|
||||||
|
)
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
Pool *pgxpool.Pool
|
Pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ type Download struct {
|
|||||||
Quality string
|
Quality string
|
||||||
State string
|
State string
|
||||||
QbitHash string
|
QbitHash string
|
||||||
SavePath string
|
SavePath *string
|
||||||
ErrorMessage string
|
ErrorMessage *string
|
||||||
QueuedAt time.Time
|
QueuedAt time.Time
|
||||||
StartedAt *time.Time
|
StartedAt *time.Time
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
@@ -107,6 +107,40 @@ func (r *DownloadRepository) GetByAlbumID(ctx context.Context, albumID string) (
|
|||||||
return downloads, nil
|
return downloads, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetActiveByTorrentID(ctx context.Context, torrentID string) (*Download, error) {
|
||||||
|
d := &Download{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM downloads WHERE torrent_id = $1 AND state NOT IN ('failed')
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, torrentID,
|
||||||
|
).Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting active download by torrent: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetActive(ctx context.Context) ([]*Download, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM downloads WHERE state IN ('pending', 'downloading') ORDER BY created_at`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing active downloads: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var downloads []*Download
|
||||||
|
for rows.Next() {
|
||||||
|
d := &Download{}
|
||||||
|
if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning download: %w", err)
|
||||||
|
}
|
||||||
|
downloads = append(downloads, d)
|
||||||
|
}
|
||||||
|
return downloads, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) {
|
func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) {
|
||||||
var exists bool
|
var exists bool
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import (
|
|||||||
"homelab.lan/music-agregator/internal/config"
|
"homelab.lan/music-agregator/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Searcher interface {
|
||||||
|
Search(query string, limit int32, indexer string) (*SearchResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
type IndexerService struct {
|
type IndexerService struct {
|
||||||
indexer Indexer
|
indexer Indexer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type MetadataService struct {
|
|||||||
client metadataPb.MetadataServiceClient
|
client metadataPb.MetadataServiceClient
|
||||||
artists *database.ArtistRepository
|
artists *database.ArtistRepository
|
||||||
albums *database.AlbumRepository
|
albums *database.AlbumRepository
|
||||||
|
tracks *database.TrackRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMetadataService(client metadataPb.MetadataServiceClient, db *database.DB) *MetadataService {
|
func NewMetadataService(client metadataPb.MetadataServiceClient, db *database.DB) *MetadataService {
|
||||||
@@ -21,6 +22,7 @@ func NewMetadataService(client metadataPb.MetadataServiceClient, db *database.DB
|
|||||||
client: client,
|
client: client,
|
||||||
artists: database.NewArtistRepository(db.Pool),
|
artists: database.NewArtistRepository(db.Pool),
|
||||||
albums: database.NewAlbumRepository(db.Pool),
|
albums: database.NewAlbumRepository(db.Pool),
|
||||||
|
tracks: database.NewTrackRepository(db.Pool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,22 +37,58 @@ func (s *MetadataService) GetAlbum(ctx context.Context, albumID string) (*metada
|
|||||||
album := resp.GetAlbum()
|
album := resp.GetAlbum()
|
||||||
|
|
||||||
if _, err := s.albums.GetByExternalID(ctx, album.GetId()); err != nil {
|
if _, err := s.albums.GetByExternalID(ctx, album.GetId()); err != nil {
|
||||||
s.persistArtist(ctx, album)
|
s.PersistArtist(ctx, album, database.Monitored)
|
||||||
s.persistAlbum(ctx, album)
|
s.PersistAlbum(ctx, album, database.Monitored)
|
||||||
}
|
}
|
||||||
|
|
||||||
return album, nil
|
return album, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistExternalID string) ([]*metadataPb.Album, error) {
|
||||||
|
resp, err := s.client.GetArtistAlbums(ctx, &metadataPb.GetArtistAlbumsRequest{
|
||||||
|
ArtistId: artistExternalID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching artist albums: %w", err)
|
||||||
|
}
|
||||||
|
return resp.GetAlbums(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MetadataService) GetAlbumTracks(ctx context.Context, albumExternalID string) ([]*metadataPb.Track, error) {
|
||||||
|
resp, err := s.client.GetAlbumTracks(ctx, &metadataPb.GetAlbumTracksRequest{
|
||||||
|
AlbumId: albumExternalID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching album tracks: %w", err)
|
||||||
|
}
|
||||||
|
return resp.GetTracks(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MetadataService) GetArtistByExternalID(ctx context.Context, externalID string) (*database.Artist, error) {
|
func (s *MetadataService) GetArtistByExternalID(ctx context.Context, externalID string) (*database.Artist, error) {
|
||||||
return s.artists.GetByExternalID(ctx, externalID)
|
return s.artists.GetByExternalID(ctx, externalID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MetadataService) GetAlbumByID(ctx context.Context, id string) (*database.Album, error) {
|
||||||
|
return s.albums.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MetadataService) GetAlbumByExternalID(ctx context.Context, externalID string) (*database.Album, error) {
|
func (s *MetadataService) GetAlbumByExternalID(ctx context.Context, externalID string) (*database.Album, error) {
|
||||||
return s.albums.GetByExternalID(ctx, externalID)
|
return s.albums.GetByExternalID(ctx, externalID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MetadataService) persistArtist(ctx context.Context, album *metadataPb.Album) {
|
func (s *MetadataService) GetAlbumsByArtistID(ctx context.Context, artistID string) ([]*database.Album, error) {
|
||||||
|
return s.albums.GetByArtistID(ctx, artistID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MetadataService) GetTracksByAlbumID(ctx context.Context, albumID string) ([]*database.Track, error) {
|
||||||
|
return s.tracks.GetByAlbumID(ctx, albumID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MetadataService) SetAlbumMonitorState(ctx context.Context, id string, state database.MonitorState) error {
|
||||||
|
return s.albums.SetMonitorState(ctx, id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MetadataService) PersistArtist(ctx context.Context, album *metadataPb.Album, state database.MonitorState) {
|
||||||
if len(album.GetArtists()) == 0 {
|
if len(album.GetArtists()) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,28 +100,34 @@ func (s *MetadataService) persistArtist(ctx context.Context, album *metadataPb.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := s.artists.Create(ctx, &database.Artist{
|
err := s.artists.Create(ctx, &database.Artist{
|
||||||
ExternalID: artist.GetId(),
|
ExternalID: artist.GetId(),
|
||||||
Name: artist.GetName(),
|
Name: artist.GetName(),
|
||||||
ArtistType: artist.GetArtistType(),
|
ArtistType: artist.GetArtistType(),
|
||||||
Country: artist.GetCountry(),
|
Country: artist.GetCountry(),
|
||||||
Genres: genres,
|
Genres: genres,
|
||||||
ImageURL: artist.GetImageUrl(),
|
ImageURL: artist.GetImageUrl(),
|
||||||
|
MonitorState: state,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Str("name", artist.GetName()).Msg("failed to persist artist")
|
log.Warn().Err(err).Str("name", artist.GetName()).Msg("failed to persist artist")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MetadataService) persistAlbum(ctx context.Context, album *metadataPb.Album) {
|
func (s *MetadataService) PersistAlbum(ctx context.Context, album *metadataPb.Album, state database.MonitorState) {
|
||||||
artistID := ""
|
s.PersistAlbumForArtist(ctx, album, "", state)
|
||||||
if len(album.GetArtists()) > 0 {
|
}
|
||||||
a, err := s.artists.GetByExternalID(ctx, album.GetArtists()[0].GetArtist().GetId())
|
|
||||||
if err == nil {
|
func (s *MetadataService) PersistAlbumForArtist(ctx context.Context, album *metadataPb.Album, artistDBID string, state database.MonitorState) {
|
||||||
artistID = a.ID
|
if artistDBID == "" {
|
||||||
|
if len(album.GetArtists()) > 0 {
|
||||||
|
a, err := s.artists.GetByExternalID(ctx, album.GetArtists()[0].GetArtist().GetId())
|
||||||
|
if err == nil {
|
||||||
|
artistDBID = a.ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if artistID == "" {
|
if artistDBID == "" {
|
||||||
log.Trace().Str("album", album.GetTitle()).Msg("skipping album persist, no artist in DB")
|
log.Trace().Str("album", album.GetTitle()).Msg("skipping album persist, no artist in DB")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,18 +143,35 @@ func (s *MetadataService) persistAlbum(ctx context.Context, album *metadataPb.Al
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := s.albums.Create(ctx, &database.Album{
|
err := s.albums.Create(ctx, &database.Album{
|
||||||
ExternalID: album.GetId(),
|
ExternalID: album.GetId(),
|
||||||
ArtistID: artistID,
|
ArtistID: artistDBID,
|
||||||
Title: album.GetTitle(),
|
Title: album.GetTitle(),
|
||||||
AlbumType: album.GetAlbumType(),
|
AlbumType: album.GetAlbumType(),
|
||||||
TotalTracks: int(album.GetTotalTracks()),
|
TotalTracks: int(album.GetTotalTracks()),
|
||||||
TotalDiscs: int(album.GetTotalDiscs()),
|
TotalDiscs: int(album.GetTotalDiscs()),
|
||||||
Label: labelName,
|
Label: labelName,
|
||||||
Genres: genres,
|
Genres: genres,
|
||||||
CoverURL: album.GetCoverUrl(),
|
CoverURL: album.GetCoverUrl(),
|
||||||
IsMonitored: true,
|
MonitorState: state,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Str("title", album.GetTitle()).Msg("failed to persist album")
|
log.Warn().Err(err).Str("title", album.GetTitle()).Msg("failed to persist album")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MetadataService) PersistTracks(ctx context.Context, albumDBID string, tracks []*metadataPb.Track) {
|
||||||
|
for _, t := range tracks {
|
||||||
|
err := s.tracks.Create(ctx, &database.Track{
|
||||||
|
ExternalID: t.GetId(),
|
||||||
|
AlbumID: albumDBID,
|
||||||
|
Title: t.GetTitle(),
|
||||||
|
DurationMS: int(t.GetDurationMs()),
|
||||||
|
ISRC: t.GetIsrc(),
|
||||||
|
DiscNumber: int(t.GetDiscNumber()),
|
||||||
|
TrackNumber: int(t.GetTrackNumber()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("title", t.GetTitle()).Msg("failed to persist track")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx
|
|||||||
}, nil
|
}, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MusicAgregatorServer) GetAlbum(ctx context.Context, req *pb.GetAlbumRequest) (*pb.GetAlbumResponse, error) {
|
||||||
|
return s.service.GetAlbum(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MusicAgregatorServer) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
|
func (s *MusicAgregatorServer) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
|
||||||
return s.service.MonitorAlbum(ctx, req)
|
return s.service.MonitorAlbum(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
+327
-11
@@ -34,12 +34,14 @@ type parsedItem struct {
|
|||||||
type MusicAgregatorService struct {
|
type MusicAgregatorService struct {
|
||||||
config config.Config
|
config config.Config
|
||||||
metadata *metadata.MetadataService
|
metadata *metadata.MetadataService
|
||||||
indexer *indexer.IndexerService
|
indexer indexer.Searcher
|
||||||
torrentClient torrent.TorrentClient
|
torrentClient torrent.TorrentClient
|
||||||
magnetResolver *torrentParser.MagnetResolver
|
magnetResolver torrentParser.Resolver
|
||||||
riverClient *river.Client[pgx.Tx]
|
riverClient *river.Client[pgx.Tx]
|
||||||
torrents *database.TorrentRepository
|
torrents *database.TorrentRepository
|
||||||
downloads *database.DownloadRepository
|
downloads *database.DownloadRepository
|
||||||
|
artists *database.ArtistRepository
|
||||||
|
downloadFiles *database.DownloadFileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorService, error) {
|
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorService, error) {
|
||||||
@@ -76,15 +78,239 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
|
|||||||
riverClient: riverClient,
|
riverClient: riverClient,
|
||||||
torrents: database.NewTorrentRepository(db.Pool),
|
torrents: database.NewTorrentRepository(db.Pool),
|
||||||
downloads: database.NewDownloadRepository(db.Pool),
|
downloads: database.NewDownloadRepository(db.Pool),
|
||||||
|
artists: database.NewArtistRepository(db.Pool),
|
||||||
|
downloadFiles: database.NewDownloadFileRepository(db.Pool),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MusicAgregatorService) Close() {
|
func NewMusicAgregatorServiceWithDeps(
|
||||||
if s.magnetResolver != nil {
|
metadata *metadata.MetadataService,
|
||||||
s.magnetResolver.Close()
|
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 closer, ok := s.magnetResolver.(interface{ Close() }); ok {
|
||||||
|
closer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *MusicAgregatorService) GetArtists(ctx context.Context, _ *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) {
|
||||||
|
dbArtists, err := service.artists.GetAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to list artists")
|
||||||
|
return nil, fmt.Errorf("listing artists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artists := make([]*pb.ArtistSummary, 0, len(dbArtists))
|
||||||
|
for _, a := range dbArtists {
|
||||||
|
albums, err := service.buildAlbumsForArtist(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("artist", a.Name).Msg("failed to build album details, returning artist without albums")
|
||||||
|
}
|
||||||
|
|
||||||
|
artists = append(artists, &pb.ArtistSummary{
|
||||||
|
Id: a.ID,
|
||||||
|
ExternalId: a.ExternalID,
|
||||||
|
Name: a.Name,
|
||||||
|
ArtistType: a.ArtistType,
|
||||||
|
Country: a.Country,
|
||||||
|
Genres: a.Genres,
|
||||||
|
ImageUrl: a.ImageURL,
|
||||||
|
MonitorState: toProtoMonitorState(a.MonitorState),
|
||||||
|
Albums: albums,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetArtistsResponse{Artists: artists}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *MusicAgregatorService) buildAlbumsForArtist(ctx context.Context, artist *database.Artist) ([]*pb.AlbumDetail, error) {
|
||||||
|
metadataAlbums, err := service.metadata.GetArtistAlbums(ctx, artist.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching metadata albums: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ma := range metadataAlbums {
|
||||||
|
service.metadata.PersistAlbumForArtist(ctx, ma, artist.ID, database.Unmonitored)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbAlbums, err := service.metadata.GetAlbumsByArtistID(ctx, artist.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("artist_id", artist.ID).Msg("failed to get local albums")
|
||||||
|
dbAlbums = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dbAlbumsByExternalID := make(map[string]*database.Album, len(dbAlbums))
|
||||||
|
for _, a := range dbAlbums {
|
||||||
|
dbAlbumsByExternalID[a.ExternalID] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
albums := make([]*pb.AlbumDetail, 0, len(metadataAlbums))
|
||||||
|
for _, ma := range metadataAlbums {
|
||||||
|
detail := &pb.AlbumDetail{
|
||||||
|
ExternalId: ma.GetId(),
|
||||||
|
Title: ma.GetTitle(),
|
||||||
|
AlbumType: ma.GetAlbumType(),
|
||||||
|
ReleaseDate: ma.GetReleaseDate(),
|
||||||
|
TotalTracks: ma.GetTotalTracks(),
|
||||||
|
TotalDiscs: ma.GetTotalDiscs(),
|
||||||
|
CoverUrl: ma.GetCoverUrl(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ma.GetLabel() != nil {
|
||||||
|
detail.Label = ma.GetLabel().GetName()
|
||||||
|
}
|
||||||
|
for _, g := range ma.GetGenres() {
|
||||||
|
detail.Genres = append(detail.Genres, g.GetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbAlbum, ok := dbAlbumsByExternalID[ma.GetId()]; ok {
|
||||||
|
detail.Id = dbAlbum.ID
|
||||||
|
detail.MonitorState = toProtoMonitorState(dbAlbum.MonitorState)
|
||||||
|
|
||||||
|
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
|
||||||
|
if err == nil && len(downloads) > 0 {
|
||||||
|
best := downloads[0]
|
||||||
|
detail.Download = &pb.DownloadInfo{
|
||||||
|
State: best.State,
|
||||||
|
Format: best.Format,
|
||||||
|
Quality: best.Quality,
|
||||||
|
SavePath: derefStr(best.SavePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detail.MonitorState = pb.MonitorState_MONITOR_STATE_UNMONITORED
|
||||||
|
}
|
||||||
|
|
||||||
|
albums = append(albums, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
return albums, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetAlbumRequest) (*pb.GetAlbumResponse, error) {
|
||||||
|
dbAlbum, err := service.metadata.GetAlbumByID(ctx, req.GetAlbumId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataAlbum, err := service.metadata.GetAlbum(ctx, dbAlbum.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get album from metadata")
|
||||||
|
return nil, fmt.Errorf("fetching album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataTracks, err := service.metadata.GetAlbumTracks(ctx, dbAlbum.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get tracks from metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
service.metadata.PersistTracks(ctx, dbAlbum.ID, metadataTracks)
|
||||||
|
|
||||||
|
album := &pb.AlbumDetail{
|
||||||
|
Id: dbAlbum.ID,
|
||||||
|
ExternalId: metadataAlbum.GetId(),
|
||||||
|
Title: metadataAlbum.GetTitle(),
|
||||||
|
AlbumType: metadataAlbum.GetAlbumType(),
|
||||||
|
ReleaseDate: metadataAlbum.GetReleaseDate(),
|
||||||
|
TotalTracks: metadataAlbum.GetTotalTracks(),
|
||||||
|
TotalDiscs: metadataAlbum.GetTotalDiscs(),
|
||||||
|
CoverUrl: metadataAlbum.GetCoverUrl(),
|
||||||
|
MonitorState: toProtoMonitorState(dbAlbum.MonitorState),
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadataAlbum.GetLabel() != nil {
|
||||||
|
album.Label = metadataAlbum.GetLabel().GetName()
|
||||||
|
}
|
||||||
|
for _, g := range metadataAlbum.GetGenres() {
|
||||||
|
album.Genres = append(album.Genres, g.GetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
|
||||||
|
if err == nil && len(downloads) > 0 {
|
||||||
|
best := downloads[0]
|
||||||
|
album.Download = &pb.DownloadInfo{
|
||||||
|
State: best.State,
|
||||||
|
Format: best.Format,
|
||||||
|
Quality: best.Quality,
|
||||||
|
SavePath: derefStr(best.SavePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadFilesByTrackID map[string]*database.DownloadFile
|
||||||
|
if album.Download != nil {
|
||||||
|
files, err := service.downloadFiles.GetByDownloadID(ctx, downloads[0].ID)
|
||||||
|
if err == nil {
|
||||||
|
downloadFilesByTrackID = make(map[string]*database.DownloadFile, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
if f.TrackID != nil {
|
||||||
|
downloadFilesByTrackID[*f.TrackID] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbTracks, _ := service.metadata.GetTracksByAlbumID(ctx, dbAlbum.ID)
|
||||||
|
dbTracksByExternalID := make(map[string]*database.Track, len(dbTracks))
|
||||||
|
for _, t := range dbTracks {
|
||||||
|
dbTracksByExternalID[t.ExternalID] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]*pb.TrackDetail, 0, len(metadataTracks))
|
||||||
|
for _, mt := range metadataTracks {
|
||||||
|
td := &pb.TrackDetail{
|
||||||
|
ExternalId: mt.GetId(),
|
||||||
|
Title: mt.GetTitle(),
|
||||||
|
DurationMs: mt.GetDurationMs(),
|
||||||
|
DiscNumber: mt.GetDiscNumber(),
|
||||||
|
TrackNumber: mt.GetTrackNumber(),
|
||||||
|
Isrc: mt.GetIsrc(),
|
||||||
|
Explicit: mt.GetExplicit(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ac := range mt.GetArtists() {
|
||||||
|
td.Artists = append(td.Artists, &pb.ArtistCredit{
|
||||||
|
Id: ac.GetArtist().GetId(),
|
||||||
|
Name: ac.GetArtist().GetName(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbTrack, ok := dbTracksByExternalID[mt.GetId()]; ok {
|
||||||
|
td.Id = dbTrack.ID
|
||||||
|
if df, ok := downloadFilesByTrackID[dbTrack.ID]; ok {
|
||||||
|
td.File = &pb.TrackFile{
|
||||||
|
Path: df.FilePath,
|
||||||
|
Format: df.FileType,
|
||||||
|
Size: df.FileSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = append(tracks, td)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetAlbumResponse{
|
||||||
|
Album: album,
|
||||||
|
Tracks: tracks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
|
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
|
||||||
album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId())
|
album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,13 +318,16 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dbAlbum, _ := service.metadata.GetAlbumByExternalID(ctx, req.GetAlbumId())
|
dbAlbum, _ := service.metadata.GetAlbumByExternalID(ctx, album.GetId())
|
||||||
if dbAlbum != nil {
|
if dbAlbum != nil {
|
||||||
|
service.metadata.SetAlbumMonitorState(ctx, dbAlbum.ID, database.Monitored)
|
||||||
|
dbAlbum.MonitorState = database.Monitored
|
||||||
|
|
||||||
qualityStr := normalizeQuality(req.GetQuality(), 0, 0)
|
qualityStr := normalizeQuality(req.GetQuality(), 0, 0)
|
||||||
owned, err := service.downloads.HasAlbumInQuality(ctx, dbAlbum.ID, req.GetQuality().String(), qualityStr)
|
owned, err := service.downloads.HasAlbumInQuality(ctx, dbAlbum.ID, req.GetQuality().String(), qualityStr)
|
||||||
if err == nil && owned {
|
if err == nil && owned {
|
||||||
log.Info().Str("album", dbAlbum.Title).Str("quality", qualityStr).Msg("album already owned in requested quality")
|
log.Info().Str("album", dbAlbum.Title).Str("quality", qualityStr).Msg("album already owned in requested quality")
|
||||||
return &pb.MonitorAlbumResponse{}, nil
|
return service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +341,7 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
|
|||||||
filtered := filterByQuality(parsed, req.GetQuality())
|
filtered := filterByQuality(parsed, req.GetQuality())
|
||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
log.Warn().Str("album", album.GetTitle()).Str("quality", req.GetQuality().String()).Msg("no releases match quality filter")
|
log.Warn().Str("album", album.GetTitle()).Str("quality", req.GetQuality().String()).Msg("no releases match quality filter")
|
||||||
return &pb.MonitorAlbumResponse{}, nil
|
return service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
best := selectBestRelease(filtered)
|
best := selectBestRelease(filtered)
|
||||||
@@ -121,15 +350,14 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbAlbum, _ = service.metadata.GetAlbumByExternalID(ctx, album.GetId())
|
||||||
if dbAlbum != nil {
|
if dbAlbum != nil {
|
||||||
service.saveTorrentAndDownload(ctx, dbAlbum.ID, best)
|
service.saveTorrentAndDownload(ctx, dbAlbum.ID, best)
|
||||||
} else {
|
} else {
|
||||||
log.Warn().Str("album_id", req.GetAlbumId()).Msg("album not in DB, skipping torrent/download persistence")
|
log.Warn().Str("album_id", req.GetAlbumId()).Msg("album not in DB, skipping torrent/download persistence")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.MonitorAlbumResponse{
|
return service.buildMonitorAlbumResponse(ctx, album, dbAlbum, &best), nil
|
||||||
Release: buildMonitoredRelease(best),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *MusicAgregatorService) searchIndexer(album *metadataPb.Album, tracker string) (*indexer.SearchResponse, error) {
|
func (service *MusicAgregatorService) searchIndexer(album *metadataPb.Album, tracker string) (*indexer.SearchResponse, error) {
|
||||||
@@ -310,6 +538,12 @@ func (service *MusicAgregatorService) saveTorrentAndDownload(ctx context.Context
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingDownload, err := service.downloads.GetActiveByTorrentID(ctx, savedTorrent.ID)
|
||||||
|
if err == nil && existingDownload != nil {
|
||||||
|
log.Info().Str("hash", best.rel.InfoHash).Str("state", existingDownload.State).Msg("active download already exists, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
download := &database.Download{
|
download := &database.Download{
|
||||||
TorrentID: savedTorrent.ID,
|
TorrentID: savedTorrent.ID,
|
||||||
AlbumID: dbAlbumID,
|
AlbumID: dbAlbumID,
|
||||||
@@ -378,6 +612,88 @@ func buildMonitoredRelease(p parsedItem) *pb.MonitoredRelease {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *MusicAgregatorService) buildMonitorAlbumResponse(ctx context.Context, metadataAlbum *metadataPb.Album, dbAlbum *database.Album, best *parsedItem) *pb.MonitorAlbumResponse {
|
||||||
|
resp := &pb.MonitorAlbumResponse{}
|
||||||
|
|
||||||
|
if best != nil {
|
||||||
|
resp.Release = buildMonitoredRelease(*best)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbAlbum != nil {
|
||||||
|
resp.Album = service.buildAlbumDetail(ctx, dbAlbum)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(metadataAlbum.GetArtists()) > 0 {
|
||||||
|
dbArtist, err := service.metadata.GetArtistByExternalID(ctx, metadataAlbum.GetArtists()[0].GetArtist().GetId())
|
||||||
|
if err == nil {
|
||||||
|
resp.Artist = &pb.ArtistSummary{
|
||||||
|
Id: dbArtist.ID,
|
||||||
|
ExternalId: dbArtist.ExternalID,
|
||||||
|
Name: dbArtist.Name,
|
||||||
|
ArtistType: dbArtist.ArtistType,
|
||||||
|
Country: dbArtist.Country,
|
||||||
|
Genres: dbArtist.Genres,
|
||||||
|
ImageUrl: dbArtist.ImageURL,
|
||||||
|
MonitorState: toProtoMonitorState(dbArtist.MonitorState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *MusicAgregatorService) buildAlbumDetail(ctx context.Context, dbAlbum *database.Album) *pb.AlbumDetail {
|
||||||
|
detail := &pb.AlbumDetail{
|
||||||
|
Id: dbAlbum.ID,
|
||||||
|
ExternalId: dbAlbum.ExternalID,
|
||||||
|
Title: dbAlbum.Title,
|
||||||
|
AlbumType: dbAlbum.AlbumType,
|
||||||
|
TotalTracks: int32(dbAlbum.TotalTracks),
|
||||||
|
TotalDiscs: int32(dbAlbum.TotalDiscs),
|
||||||
|
Label: dbAlbum.Label,
|
||||||
|
Genres: dbAlbum.Genres,
|
||||||
|
CoverUrl: dbAlbum.CoverURL,
|
||||||
|
MonitorState: toProtoMonitorState(dbAlbum.MonitorState),
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbAlbum.ReleaseDate != nil {
|
||||||
|
detail.ReleaseDate = dbAlbum.ReleaseDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
|
||||||
|
if err == nil && len(downloads) > 0 {
|
||||||
|
best := downloads[0]
|
||||||
|
detail.Download = &pb.DownloadInfo{
|
||||||
|
State: best.State,
|
||||||
|
Format: best.Format,
|
||||||
|
Quality: best.Quality,
|
||||||
|
SavePath: derefStr(best.SavePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoMonitorState(state database.MonitorState) pb.MonitorState {
|
||||||
|
switch state {
|
||||||
|
case database.Monitored:
|
||||||
|
return pb.MonitorState_MONITOR_STATE_MONITORED
|
||||||
|
case database.Unmonitored:
|
||||||
|
return pb.MonitorState_MONITOR_STATE_UNMONITORED
|
||||||
|
case database.Excluded:
|
||||||
|
return pb.MonitorState_MONITOR_STATE_EXCLUDED
|
||||||
|
default:
|
||||||
|
return pb.MonitorState_MONITOR_STATE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
func downloadTorrentData(url string) ([]byte, error) {
|
func downloadTorrentData(url string) ([]byte, error) {
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Resolver interface {
|
||||||
|
Resolve(magnetURI string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
type MagnetResolver struct {
|
type MagnetResolver struct {
|
||||||
client *torrent.Client
|
client *torrent.Client
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadA
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *PollDownloadWorker) reschedule(ctx context.Context, args PollDownloadArgs) error {
|
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{
|
_, err := w.RiverClient.Insert(ctx, args, &river.InsertOpts{
|
||||||
ScheduledAt: time.Now().Add(args.CheckInterval),
|
ScheduledAt: time.Now().Add(args.CheckInterval),
|
||||||
})
|
})
|
||||||
@@ -115,6 +119,36 @@ func (w *PollDownloadWorker) reschedule(ctx context.Context, args PollDownloadAr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *PollDownloadWorker) RecoverOrphanedDownloads(ctx context.Context) {
|
||||||
|
active, err := w.Downloads.GetActive(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to query active downloads for recovery")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(active) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range active {
|
||||||
|
_, err := w.RiverClient.Insert(ctx, PollDownloadArgs{
|
||||||
|
DownloadID: d.ID,
|
||||||
|
TorrentHash: d.QbitHash,
|
||||||
|
CheckInterval: 30 * time.Second,
|
||||||
|
}, &river.InsertOpts{
|
||||||
|
ScheduledAt: time.Now().Add(5 * time.Second),
|
||||||
|
UniqueOpts: river.UniqueOpts{
|
||||||
|
ByArgs: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("download_id", d.ID).Msg("failed to reschedule orphaned download")
|
||||||
|
} else {
|
||||||
|
log.Info().Str("download_id", d.ID).Str("hash", d.QbitHash).Msg("recovered orphaned download poll job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var audioExtensions = map[string]bool{
|
var audioExtensions = map[string]bool{
|
||||||
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
|
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
|
||||||
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
|
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ option go_package = "homelab.lan/music-agregator/gen/music_agregator/v1/";
|
|||||||
|
|
||||||
service MusicAgregatorService {
|
service MusicAgregatorService {
|
||||||
rpc MonitorAlbum(MonitorAlbumRequest) returns (MonitorAlbumResponse) {}
|
rpc MonitorAlbum(MonitorAlbumRequest) returns (MonitorAlbumResponse) {}
|
||||||
|
rpc GetArtists(GetArtistsRequest) returns (GetArtistsResponse) {}
|
||||||
|
rpc GetAlbum(GetAlbumRequest) returns (GetAlbumResponse) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
message MonitorAlbumRequest {
|
message MonitorAlbumRequest {
|
||||||
@@ -23,7 +25,89 @@ enum QualityType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message MonitorAlbumResponse {
|
message MonitorAlbumResponse {
|
||||||
MonitoredRelease release = 1;
|
AlbumDetail album = 1;
|
||||||
|
ArtistSummary artist = 2;
|
||||||
|
MonitoredRelease release = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetArtistsRequest {}
|
||||||
|
|
||||||
|
message GetArtistsResponse {
|
||||||
|
repeated ArtistSummary artists = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MonitorState {
|
||||||
|
MONITOR_STATE_UNSPECIFIED = 0;
|
||||||
|
MONITOR_STATE_MONITORED = 1;
|
||||||
|
MONITOR_STATE_UNMONITORED = 2;
|
||||||
|
MONITOR_STATE_EXCLUDED = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ArtistSummary {
|
||||||
|
string id = 1;
|
||||||
|
string external_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
string artist_type = 4;
|
||||||
|
string country = 5;
|
||||||
|
repeated string genres = 6;
|
||||||
|
string image_url = 7;
|
||||||
|
MonitorState monitor_state = 8;
|
||||||
|
repeated AlbumDetail albums = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlbumDetail {
|
||||||
|
string id = 1;
|
||||||
|
string external_id = 2;
|
||||||
|
string title = 3;
|
||||||
|
string album_type = 4;
|
||||||
|
string release_date = 5;
|
||||||
|
int32 total_tracks = 6;
|
||||||
|
int32 total_discs = 7;
|
||||||
|
string cover_url = 8;
|
||||||
|
repeated string genres = 9;
|
||||||
|
string label = 10;
|
||||||
|
MonitorState monitor_state = 11;
|
||||||
|
DownloadInfo download = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DownloadInfo {
|
||||||
|
string state = 1;
|
||||||
|
string format = 2;
|
||||||
|
string quality = 3;
|
||||||
|
string save_path = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetAlbumRequest {
|
||||||
|
string album_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetAlbumResponse {
|
||||||
|
AlbumDetail album = 1;
|
||||||
|
repeated TrackDetail tracks = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TrackDetail {
|
||||||
|
string id = 1;
|
||||||
|
string external_id = 2;
|
||||||
|
string title = 3;
|
||||||
|
int32 duration_ms = 4;
|
||||||
|
int32 disc_number = 5;
|
||||||
|
int32 track_number = 6;
|
||||||
|
string isrc = 7;
|
||||||
|
bool explicit = 8;
|
||||||
|
repeated ArtistCredit artists = 9;
|
||||||
|
TrackFile file = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ArtistCredit {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TrackFile {
|
||||||
|
string path = 1;
|
||||||
|
string format = 2;
|
||||||
|
int64 size = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MonitoredRelease {
|
message MonitoredRelease {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user