Compare commits

...

28 Commits

Author SHA1 Message Date
Alexander 3e8b8153b6 Bruno requests 2026-05-11 19:52:55 +02:00
Alexander 69752bd6a2 Update flow diagrams for event bus architecture, cancel cleanup, and SubscribeEvents
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:54:32 +02:00
Alexander 93821ab214 Add streaming, subscribe, cancel cleanup, and recovery component tests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:54:25 +02:00
Alexander be859e87c0 Add DeleteTorrent to torrent client interface for cancel cleanup
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:54:17 +02:00
Alexander e5bdf2c4ce Add SubscribeEvents RPC, AlbumEvent message, deprecate unary MonitorAlbum
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:54:09 +02:00
Alexander 5a5660bf21 Refactor MonitorAlbumStream: EventPublisher interface, background workflows, DB-before-qBit save order
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:54:01 +02:00
Alexander 52e81faedd Add workflow registry for background goroutine lifecycle management
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:53:52 +02:00
Alexander 7582279077 Add in-process event bus with ring buffer for workflow event broadcasting
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:53:43 +02:00
Alexander f52e9abb0a Add WorkflowRun and AlbumEvent repositories with download cancel support
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:53:35 +02:00
Alexander 7d11b729a5 Add DB migration for workflow_runs, album_events tables and cancelled download state
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 15:53:27 +02:00
Alexander eab92dd40b Add SearchArtists and GetArtistAlbums proxy RPCs to music-agregator service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 10:30:18 +02:00
Alexander ad03caa3f4 Add streaming flow diagrams, update existing flows with streaming references
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 10:26:45 +02:00
Alexander 24f355c5ae Add MonitorAlbumStream bidirectional streaming RPC with automatic and manual interaction modes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 10:26:37 +02:00
Alexander f5e2f764b5 Optimize GetArtists: parallel artist processing, batch album upserts, batch download lookups, retry on metadata calls 2026-05-10 00:06:58 +02:00
Alexander 6320f37240 Deduplicate GetAlbum response: merge release info into AlbumDetail, track release into TrackDetail 2026-05-09 23:23:34 +02:00
Alexander 2740585261 Add album/track releases with audio analysis, AnalyzeAlbumRelease RPC, Docker path auto-resolution, release parsing decision tree 2026-05-09 23:16:59 +02:00
Alexander 1e8506f146 Deduce bit depth, sample rate, and bitrate from file sizes; add comprehensive parser tests 2026-05-09 22:09:17 +02:00
Alexander 7fa859e815 Remove rutracker parser, replace with GenericParser for all indexer results 2026-05-09 21:50:55 +02:00
Alexander ef75b9bfba Share single torrent client across all services, eliminate triple qBittorrent login on startup 2026-05-09 21:42:53 +02:00
Alexander 758a4b909a Leftovers 2026-05-09 21:31:24 +02:00
Alexander 31ec3f9826 Add MonitorAlbum component tests: 21 cases covering all flow diagrams (bufconn + testcontainers + hand-rolled mocks) 2026-05-09 21:31:09 +02:00
Alexander 6f31698006 Fix monitor state: never downgrade on upsert, explicitly set monitored on MonitorAlbum 2026-05-09 20:26:38 +02:00
Alexander 3ce6e23421 Fix duplicate download insert: handle NULL columns in download scan, check by torrent ID, enrich MonitorAlbum response, recover orphaned downloads on startup 2026-05-09 20:13:43 +02:00
Alexander cca404bcc0 Enrich MonitorAlbum response, prevent duplicate downloads, recover orphaned jobs on startup 2026-05-09 20:01:53 +02:00
Alexander 5257ed0f1b Fix album persistence by passing artist DB ID directly to PersistAlbum 2026-05-09 11:18:52 +02:00
Alexander 8c60fe5e35 Add GetAlbum RPC with track details and persist metadata on discovery 2026-05-09 10:47:06 +02:00
Alexander e61e58be72 Expand GetArtists with album details, download info, and generic MonitorState enum 2026-05-08 23:00:42 +02:00
Alexander e49cc25372 Add GetArtists RPC with artist monitor state (monitored/unmonitored/excluded) 2026-05-08 22:27:56 +02:00
131 changed files with 11425 additions and 5736 deletions
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Analyze Album
type: grpc
seq: 10
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/AnalyzeAlbumRelease
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"album_id": "1d51e4a7-1a8b-4160-bd08-1aee658a991a"
}
'''
}
+22
View File
@@ -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"
}
'''
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Get Artist Albums
type: grpc
seq: 11
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/GetArtistAlbums
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"artist_id": "27e2997f-f7a1-4353-bcc4-57b9274fa9a4"
}
'''
}
+20
View File
@@ -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: '''
{}
'''
}
+30
View File
@@ -0,0 +1,30 @@
meta {
name: Search Albums
type: grpc
seq: 5
}
grpc {
url: localhost:3000
method: /metadata.v1.MetadataService/SearchAlbums
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"query": "desidero",
"artist": "corrigo",
"limit": 770,
"offset": 396,
"provider": 0,
"album_types": [
"spiculum",
"spiculum"
]
}
'''
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: SearchArtists
type: grpc
seq: 10
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/SearchArtists
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"query": "metal"
}
'''
}
+23 -8
View File
@@ -26,6 +26,7 @@ import (
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
"homelab.lan/music-agregator/internal"
"homelab.lan/music-agregator/internal/analysis"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/hello"
@@ -82,12 +83,16 @@ type riverSetup struct {
cacheRefreshWorker *indexer.CacheRefreshWorker
}
func setupRiver(ctx context.Context, cfg config.Config, db *database.DB) *riverSetup {
func setupRiver(ctx context.Context, cfg config.Config, db *database.DB, torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper) *riverSetup {
cacheWorker := &indexer.CacheRefreshWorker{}
pollWorker := &workers.PollDownloadWorker{
Downloads: database.NewDownloadRepository(db.Pool),
DownloadFiles: database.NewDownloadFileRepository(db.Pool),
TorrentClient: torrent.MustNewTorrentClient(cfg),
AlbumReleases: database.NewAlbumReleaseRepository(db.Pool),
TrackReleases: database.NewTrackReleaseRepository(db.Pool),
TorrentClient: torrentClient,
PathMapper: pathMapper,
Analyzer: analysis.NewReleaseAnalyzer(db),
}
riverWorkers := river.NewWorkers()
@@ -113,6 +118,8 @@ func setupRiver(ctx context.Context, cfg config.Config, db *database.DB) *riverS
log.Info().Msg("River queue started")
pollWorker.RecoverOrphanedDownloads(ctx)
return &riverSetup{
client: riverClient,
cacheRefreshWorker: cacheWorker,
@@ -153,9 +160,20 @@ func serveGrpc(config config.Config) {
ctx := context.Background()
db := setupDatabase(ctx, config)
defer db.Close()
rs := setupRiver(ctx, config, db)
musiscAgregatorSeerver, err := internal.NewMusicAgregatorServer(config, rs.client, db)
torrentClient, err := torrent.NewTorrentClient(config)
if err != nil {
log.Fatal().Err(err).Msg("failed to create torrent client")
}
pathMapper, err := torrent.NewPathMapper(config.Torrent.ContainerName, torrentClient)
if err != nil {
log.Fatal().Err(err).Msg("failed to create path mapper")
}
rs := setupRiver(ctx, config, db, torrentClient, pathMapper)
musiscAgregatorSeerver, err := internal.NewMusicAgregatorServer(config, rs.client, torrentClient, pathMapper, db)
if err != nil {
log.Fatal().Err(err).Msg("failed to create MusicAgregatorServer")
}
@@ -163,10 +181,7 @@ func serveGrpc(config config.Config) {
if err != nil {
log.Fatal().Err(err).Msg("failed to create IndexerServer")
}
torrentServer, err := torrent.NewTorrentServer(config)
if err != nil {
log.Fatal().Err(err).Msg("failed to create TorrentServer")
}
torrentServer := torrent.NewTorrentServer(torrentClient)
metadataServer, err := metadata.NewMetadataServer(config)
if err != nil {
log.Fatal().Err(err).Msg("failed to create MetadataServer")
@@ -0,0 +1,112 @@
CREATE TYPE monitor_state AS ENUM ('unmonitored', 'monitored', 'excluded');
CREATE TYPE download_state AS ENUM ('pending', 'downloading', 'completed', 'failed', 'seeding');
CREATE TABLE artists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
artist_type VARCHAR(50) NOT NULL,
country VARCHAR(10),
genres TEXT[] NOT NULL DEFAULT '{}',
image_url TEXT,
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_artists_monitor_state ON artists(monitor_state);
CREATE TABLE albums (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) NOT NULL UNIQUE,
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
album_type VARCHAR(50) NOT NULL,
release_date DATE,
total_tracks INTEGER NOT NULL DEFAULT 0,
total_discs INTEGER NOT NULL DEFAULT 1,
label VARCHAR(255),
genres TEXT[] NOT NULL DEFAULT '{}',
cover_url TEXT,
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_albums_artist_id ON albums(artist_id);
CREATE INDEX idx_albums_monitor_state ON albums(monitor_state);
CREATE TABLE tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) NOT NULL UNIQUE,
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
isrc VARCHAR(20),
disc_number INTEGER NOT NULL DEFAULT 1,
track_number INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tracks_album_id ON tracks(album_id);
CREATE TABLE torrents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
info_hash VARCHAR(64) NOT NULL UNIQUE,
tracker VARCHAR(100) NOT NULL,
title TEXT NOT NULL,
format VARCHAR(20) NOT NULL,
quality VARCHAR(20) NOT NULL,
source VARCHAR(50),
bit_depth INTEGER,
sample_rate INTEGER,
seeders INTEGER NOT NULL DEFAULT 0,
peers INTEGER NOT NULL DEFAULT 0,
size BIGINT NOT NULL DEFAULT 0,
track_count INTEGER NOT NULL DEFAULT 0,
has_cover_art BOOLEAN NOT NULL DEFAULT false,
has_cue_sheet BOOLEAN NOT NULL DEFAULT false,
has_rip_log BOOLEAN NOT NULL DEFAULT false,
download_link TEXT,
torrent_file BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_torrents_album_id ON torrents(album_id);
CREATE TABLE downloads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
torrent_id UUID NOT NULL REFERENCES torrents(id) ON DELETE CASCADE,
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
format VARCHAR(20) NOT NULL,
quality VARCHAR(20) NOT NULL,
state download_state NOT NULL DEFAULT 'pending',
qbit_hash VARCHAR(64),
save_path TEXT,
error_message TEXT,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_downloads_album_id ON downloads(album_id);
CREATE INDEX idx_downloads_torrent_id ON downloads(torrent_id);
CREATE INDEX idx_downloads_state ON downloads(state);
CREATE TABLE download_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
download_id UUID NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
file_path TEXT NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
file_type VARCHAR(50) NOT NULL,
sha256_hash VARCHAR(64),
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_download_files_download_id ON download_files(download_id);
@@ -0,0 +1,33 @@
CREATE TABLE workflow_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
quality VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running',
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
running_lock BOOLEAN GENERATED ALWAYS AS (CASE WHEN status = 'running' THEN TRUE ELSE NULL END) STORED,
CONSTRAINT idx_workflow_runs_active UNIQUE (album_id, quality, running_lock)
);
CREATE INDEX idx_workflow_runs_status ON workflow_runs(status);
CREATE TABLE album_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seq BIGSERIAL NOT NULL,
workflow_run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
album_id UUID NOT NULL,
event_type VARCHAR(20) NOT NULL,
step VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
data_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_album_events_workflow ON album_events(workflow_run_id);
CREATE INDEX idx_album_events_album ON album_events(album_id);
CREATE INDEX idx_album_events_seq ON album_events(seq);
ALTER TYPE download_state ADD VALUE IF NOT EXISTS 'cancelled';
-225
View File
@@ -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
@@ -0,0 +1,86 @@
@startuml Event Bus Architecture
skinparam componentAlign center
title Event Bus: In-Process Pub/Sub Architecture
package "Publishers" {
[Workflow Goroutine 1\n(album A, LOSSLESS)] as WF1
[Workflow Goroutine 2\n(album B, LOSSY)] as WF2
}
database "PostgreSQL" as DB {
[workflow_runs] as WR
[album_events] as AE
}
package "Event Bus (in-memory)" {
[Topic: albumA:LOSSLESS] as T1
[Topic: albumB:LOSSY] as T2
[Global Subscribers] as GS
}
package "Subscribers" {
[MonitorAlbumStream\nClient A (album A)] as S1
[MonitorAlbumStream\nClient B (album A)] as S2
[SubscribeEvents\nClient C (global)] as S3
}
WF1 --> DB : 1. Write event\n(synchronous)
WF1 --> T1 : 2. Publish\n(async notification)
WF2 --> DB : 1. Write event
WF2 --> T2 : 2. Publish
T1 --> S1 : Ring buffer\n(per subscriber)
T1 --> S2 : Ring buffer
T1 --> GS
T2 --> GS
GS --> S3 : Ring buffer
note right of DB
**Source of truth.**
Events survive restarts.
Replay via seq numbers.
end note
note right of T1
**Ephemeral notification.**
Ring buffer per subscriber.
Slow subscribers: overwrite oldest.
No backpressure on publishers.
end note
note bottom of S1
Client disconnect removes
subscriber from topic.
Workflow continues.
end note
== Subscription Lifecycle ==
note as N1
**Subscribe flow:**
1. Client calls MonitorAlbumStream or SubscribeEvents
2. Server subscribes to EventBus (per-topic or global)
3. Server queries DB for historical events (replay)
4. Server bridges: EventBus → gRPC stream
5. On disconnect: cleanup func unsubscribes
**Topic cleanup:**
When last subscriber leaves AND workflow completed:
topic removed from EventBus map.
end note
== Recovery on Restart ==
note as N2
**Server restart recovery:**
1. Query workflow_runs WHERE status = 'running'
2. For each stale run:
- If active download exists → mark completed
- Otherwise → mark failed ("server restarted")
3. RecoverOrphanedDownloads reschedules poll jobs
4. New workflows start fresh (no goroutine resurrection)
end note
@enduml
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
@@ -0,0 +1,109 @@
@startuml MonitorAlbumStream - Already Owned Scenarios
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Already Owned Handling
actor Client
participant "monitorWorkflow" as Workflow
participant "EventBus" as Bus
database "PostgreSQL" as DB
participant "IndexerService" as Indexer
note over Client, Indexer #lightblue
All events are persisted to album_events table (DB first)
then published to EventBus for live subscribers.
In automatic mode, workflow runs as background goroutine.
end note
== Scenario A: Automatic Mode - Early Return ==
Client -> Workflow: StartMonitorRequest(mode=AUTOMATIC)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Fetching album metadata..."
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Got: Artist - Title"\nData: StreamAlbumInfo
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: StatusUpdate(COMPLETE)
note right #lightgreen: "Already owned"
Workflow ->> Client: MonitorAlbumResponse
note right: album: monitored\ndownload: existing info\nrelease: nil (no search)
note over Client, Workflow #lightblue
**Automatic Mode**: Skips search entirely.
Returns immediately with existing download info.
end note
== Scenario B: Manual Mode - User Confirms Continue ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false
Client -> Workflow: UserDecision(confirm=true)
note right #lightgreen: User chooses\nto continue
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
note right: Proceeds with normal flow...
Workflow -> Indexer: Search()
note right: ... continues to completion
== Scenario C: Manual Mode - User Skips ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"
Client -> Workflow: UserDecision(confirm=false)
note right #lightyellow: User chooses\nto skip
Workflow ->> Client: StatusUpdate(COMPLETE)
note right: "Skipped - already owned"
Workflow ->> Client: MonitorAlbumResponse
note right: album: monitored\ndownload: existing info\nrelease: nil
== Scenario D: Manual Mode - Timeout ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow ->> Client: PromptForDecision
note over Client, Workflow #lightyellow
Client does not respond within timeout (max: 300s)
end note
Workflow -> Workflow: Use default decision
note right: default: false\n(skip download)
Workflow ->> Client: StatusUpdate(COMPLETE)
note right: "Skipped - already owned"
Workflow ->> Client: MonitorAlbumResponse
@enduml
@@ -0,0 +1,99 @@
@startuml MonitorAlbumStream - Automatic Mode Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Automatic Mode (Fire-and-Forget)
actor Client
participant "gRPC Server" as Server
participant "WorkflowRegistry" as Registry
participant "EventBus" as Bus
participant "monitorWorkflow\n(background goroutine)" as Workflow
database "PostgreSQL" as DB
participant "MusicAgregatorService" as Service
participant "IndexerService" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
== 1. Initialize Stream ==
Client -> Server: MonitorAlbumStream()
Server -> Client: stream established
Client -> Server: StartMonitorRequest
note right: album_id, quality\nmode = AUTOMATIC
== 2. Start or Subscribe to Workflow ==
Server -> Registry: GetOrCreate(albumID, quality)
alt New workflow
Registry --> Server: (entry, created=true)
Server -> Bus: Subscribe(topic)
note right #lightblue: Subscribe BEFORE\nstarting goroutine\n(no missed events)
Server -> Workflow: go workflow.run(entry.Ctx)
note right #lightyellow: Background goroutine.\nDecoupled from stream context.\nClient can disconnect freely.
else Existing workflow
Registry --> Server: (entry, created=false)
Server -> Bus: Subscribe(topic)
Server -> DB: Replay album_events\n(since last seq)
DB --> Server: historical events
Server ->> Client: replayed events
end
== 3. Event Bridge (concurrent) ==
note over Client, Workflow #lightblue
**Left side**: Event bus → gRPC stream bridge
**Right side**: Workflow executing in background
Both run concurrently. Client disconnect only stops the bridge.
end note
|||
Workflow -> DB: album_events.Create(FETCHING_METADATA)
Workflow -> Bus: Publish(FETCHING_METADATA)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow -> Service: getAlbumWithPersist()
Workflow -> DB: workflow_runs.Create(albumID, quality)
Workflow -> DB: album_events.Create(CHECKING_OWNED)
Workflow -> Bus: Publish(CHECKING_OWNED)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> Indexer: Search()
Workflow -> DB: album_events.Create(PARSING_RESULTS)
Workflow -> Bus: Publish(PARSING_RESULTS)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(PARSING_RESULTS)
Workflow -> Workflow: filterByQuality + selectBest
Workflow -> DB: saveTorrentAndDownload()
note right: DB save BEFORE qBit add\n(prevents orphan torrents)
Workflow -> QBit: AddMagnet()
Workflow -> DB: album_events.Create(COMPLETE)
Workflow -> Bus: Publish(COMPLETE)
Bus ->> Server: event notification
Server ->> Client: MonitorAlbumResponse
note right #lightgreen: Final result
Workflow -> DB: workflow_runs.SetCompleted()
Workflow -> Registry: Remove(albumID, quality)
== 4. Client Disconnect (Fire-and-Forget) ==
note over Client, Workflow #lightyellow
Client can disconnect at ANY point during the workflow.
The workflow goroutine continues independently.
Another client can subscribe to the same workflow later.
end note
@enduml
@@ -0,0 +1,157 @@
@startuml MonitorAlbumStream - Manual Mode Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Manual Mode (Interactive Prompts)
actor Client
participant "gRPC Server" as Server
participant "monitorWorkflow" as Workflow
participant "MusicAgregatorService" as Service
database "PostgreSQL" as DB
participant "IndexerService" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
== 1. Initialize Stream ==
Client -> Server: MonitorAlbumStream()
Server -> Client: stream established
Client -> Server: StartMonitorRequest
note right: album_id, quality\nmode = MANUAL
Server -> Workflow: newMonitorWorkflow()
== 2. Fetch Metadata ==
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Fetching album metadata..."
Workflow -> Service: getAlbumWithPersist()
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: Data: StreamAlbumInfo\n{artist, title, release_date,\nalready_owned, owned_quality}
== 3. Check Ownership (Interactive) ==
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true (already owned!)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
note right #lightyellow: "Already owned in FLAC quality"
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false\ntimeout: max 300s
Client -> Server: UserDecision
note right: confirm: true\n(user chooses to continue)
Workflow -> Workflow: Continue with search
== 4. Search & Parse ==
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
Workflow -> Indexer: Search()
Indexer --> Workflow: 3 results
loop parse results
Workflow -> Magnet: Resolve()
end
Workflow ->> Client: StatusUpdate(PARSING_RESULTS)
note right #lightblue: Data: TorrentList\n[{id, title, seeders, format}, ...]
== 5. Select Torrents (Interactive) ==
Workflow ->> Client: PromptForDecision
note right #orange: type: SELECT_MANY\nmessage: "Select torrents to consider"\noptions: [{id, label, description}, ...]\ndefault: all selected\nmin: 1, max: N
Client -> Server: UserDecision
note right: selected_ids: ["torrent-0", "torrent-2"]\n(user deselects torrent-1)
Workflow -> Workflow: Filter to selected torrents
== 6. Filter by Quality ==
Workflow ->> Client: StatusUpdate(FILTERING_QUALITY)
Workflow -> Workflow: filterByQuality()
note right: 2 releases remain\nafter quality filter
== 7. Select Release (Interactive) ==
Workflow ->> Client: PromptForDecision
note right #orange: type: SELECT_ONE\nmessage: "Select release"\noptions: [{id, label, description}, ...]\ndefault: highest seeders
Client -> Server: UserDecision
note right: selected_id: "release-1"\n(user picks specific release)
Workflow ->> Client: StatusUpdate(SELECTING_RELEASE)
note right #lightblue: Data: ReleaseInfo\n{hash, format, seeders, tracker}
== 8. Confirm Add (Interactive) ==
Workflow ->> Client: StatusUpdate(ADDING_TORRENT)
note right #lightyellow: "Adding torrent: Title..."
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Add torrent 'Title' to client?"\nconfirm_label: "Add"\ncancel_label: "Skip"\ndefault: true
Client -> Server: UserDecision
note right: confirm: true
== 9. Add & Save ==
Workflow -> QBit: AddMagnet()
QBit --> Workflow: OK
Workflow ->> Client: StatusUpdate(SAVING)
Workflow -> DB: Create torrent & download
== 10. Complete ==
Workflow ->> Client: StatusUpdate(COMPLETE)
note right #lightblue: "Download started"
Workflow ->> Client: MonitorAlbumResponse
note right #lightgreen: Final result
== Cancel Cleanup (Disconnect or Cancel Message) ==
note over Client, QBit #salmon
**Manual Mode: Disconnect = Cancel**
When client disconnects or sends CancelRequest:
1. Workflow context is cancelled (stops further processing)
2. If torrent was added to qBit: **DeleteTorrent(hash)** removes it + files
3. If download record exists: marked as **cancelled** in DB
4. workflow_run marked as **cancelled** in DB
5. All events persisted to album_events for audit trail
Cleanup uses a fresh context (not the cancelled one).
end note
== Decision Points Summary ==
note over Client, QBit #lightyellow
**Manual Mode Decision Points:**
1. **CHECKING_OWNED** (CONFIRM)
- Triggered when: Album already owned in requested quality
- Default: false (skip)
- Timeout action: Use default
2. **PARSING_RESULTS** (SELECT_MANY)
- Triggered when: Multiple torrents found (>1)
- Default: All selected
- Timeout action: Use defaults
3. **SELECTING_RELEASE** (SELECT_ONE)
- Triggered when: Multiple releases after quality filter (>1)
- Default: Highest seeders
- Timeout action: Use default
4. **ADDING_TORRENT** (CONFIRM)
- Triggered: Always in manual mode
- Default: true (add)
- Timeout action: Use default
end note
@enduml
@@ -0,0 +1,144 @@
@startuml MonitorAlbumStream Protocol
skinparam sequenceMessageAlign center
title MonitorAlbumStream & SubscribeEvents: Message Protocol
participant "Client" as C
participant "Server" as S
== MonitorAlbumStream (Bidirectional) ==
C -> S: gRPC MonitorAlbumStream()
note right: Opens bidirectional stream
== Request Messages (Client -> Server) ==
C -> S: MonitorAlbumStreamRequest
note right #lightblue
**oneof message:**
- **start**: StartMonitorRequest
- album_id (required)
- quality: LOSSLESS | LOSSY | UNSPECIFIED
- mode: AUTOMATIC (0) | MANUAL (1)
- indexer_options: {tracker}
- **decision**: UserDecision
- prompt_id (must match pending prompt)
- confirm | selected_id | selected_ids
- **cancel**: CancelRequest
- Gracefully terminates workflow
end note
== Response Messages (Server -> Client) ==
S -> C: MonitorAlbumStreamResponse
note left #lightgreen
**oneof message:**
- **status**: StatusUpdate
- step: MonitorStep enum
- message: human-readable text
- data: StreamAlbumInfo | TorrentList | ReleaseInfo
- **prompt**: PromptForDecision
- prompt_id: unique identifier
- type: CONFIRM | SELECT_ONE | SELECT_MANY
- message: prompt text
- timeout_seconds: response deadline
- options: confirm | select_one | select_many config
- **result**: MonitorAlbumResponse
- Final response (stream ends after this)
- **error**: ErrorUpdate
- failed_step: where error occurred
- message: error description
- recoverable: bool
end note
== SubscribeEvents (Server-Side Stream) ==
C -> S: SubscribeEvents(SubscribeEventsRequest)
note right #lightyellow
**SubscribeEventsRequest:**
- since_seq: int64 (0 = live only, >0 = replay from seq)
**Response stream: AlbumEvent**
- seq: int64 (monotonic sequence number)
- workflow_run_id, album_id, quality
- event_type: status | error | result
- step: MonitorStep name
- message: human-readable text
- data_json: bytes (optional structured data)
- timestamp_ms: int64
**Global firehose**: receives events from ALL running workflows.
Client-side filtering by album_id if needed.
end note
== Event Flow Architecture ==
note over C, S #lightblue
**Event Path (DB first, bus second):**
1. Workflow step executes
2. Event written to **album_events** table (synchronous, durable)
3. Event published to **EventBus** (async, ephemeral notification)
4. Bus fans out to subscribers:
- MonitorAlbumStream clients (per-topic)
- SubscribeEvents clients (global)
5. Subscribers convert event to proto and stream.Send()
**DB is source of truth. Bus is notification layer.**
Events are never lost, even if no subscribers are connected.
end note
== Monitor Steps ==
note over C, S #lightyellow
**MonitorStep Enum:**
1. FETCHING_METADATA - Getting album info from metadata service
2. CHECKING_OWNED - Checking if already downloaded
3. SEARCHING_INDEXER - Querying Jackett/indexers
4. PARSING_RESULTS - Resolving magnets, parsing torrents
5. FILTERING_QUALITY - Applying quality filter
6. SELECTING_RELEASE - Choosing best/user-selected release
7. ADDING_TORRENT - Adding to qBittorrent
8. SAVING - Persisting to database
9. COMPLETE - Workflow finished
end note
== Mode Comparison ==
note over C, S #lightyellow
**AUTOMATIC mode:**
- Workflow runs as background goroutine
- Client can disconnect, workflow continues (fire-and-forget)
- Duplicate request for same album+quality subscribes to existing
- Events delivered via EventBus bridge
**MANUAL mode:**
- Workflow runs inline in stream handler
- Interactive prompts at 4 decision points
- Disconnect = cancel = full cleanup (qBit delete + DB cancel)
- Events delivered directly via stream + persisted to DB
end note
== Persistence ==
note over C, S #lightgreen
**workflow_runs table:**
- Tracks workflow lifecycle: running → completed | failed | cancelled
- Unique constraint: one running workflow per album+quality
- Used for deduplication, recovery, and audit
**album_events table:**
- Audit log of all workflow events
- seq BIGSERIAL for ordering and replay
- Supports subscribe-before-query replay pattern
end note
== Timeout Behavior ==
note over C, S #pink
When prompt times out (max: 300s):
- Server uses **default decision** value
- Workflow continues automatically
- No error is raised
end note
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

@@ -0,0 +1,193 @@
@startuml Release Parsing Decision Tree
skinparam ActivityBackgroundColor #f8f8f8
skinparam ActivityBorderColor #333333
skinparam DiamondBackgroundColor #fffde7
skinparam NoteBackgroundColor #e3f2fd
title Release Parsing Decision Tree
start
partition "1. Resolve Torrent Data" {
if (DownloadLink starts\nwith "magnet:?") then (yes)
:MagnetResolver.Resolve(magnetURI);
note right
DHT lookup via anacrolix/torrent
30s timeout, 15s early exit
if peers but none active
end note
if (Resolve succeeded?) then (yes)
:torrentData = resolved bytes;
else (no)
:fallback to **title-only parse**;
note right
parser.Parse(item.Title)
No torrent data available
No info_hash computed
end note
goto TitleOnlyParse
endif
else (HTTP link)
:downloadTorrentData(url);
note right
HTTP GET with 30s timeout
Expects .torrent file bytes
end note
if (Download succeeded?) then (yes)
:torrentData = downloaded bytes;
else (no)
:fallback to **title-only parse**;
goto TitleOnlyParse
endif
endif
}
partition "2. ParseTorrent (torrentData + metadata album)" {
partition "2a. Fill from Metadata Album" {
:Artist = album.Artists[0].Name;
:Album = album.Title;
:Year = album.ReleaseDate[:4];
:Type = album.AlbumType
(album/ep/single/compilation/...);
:Genres = album.Genres[].Name;
:Label = album.Label.Name;
:TrackCount = album.TotalTracks;
:ReleaseCount = album.TotalDiscs;
}
partition "2b. Fill from Torrent Data" {
:metainfo.Load(torrentData);
note right
Bencode decode via
anacrolix/torrent/metainfo
end note
if (Parse failed?) then (yes)
:Append to ParseErrors;
:Skip torrent analysis;
else (no)
:info = mi.UnmarshalInfo();
if (Unmarshal failed?) then (yes)
:Append to ParseErrors;
:Skip torrent analysis;
else (no)
:RawTitle = info.Name;
:InfoHash = SHA1(info dict);
if (info.Files is empty?\n(single-file torrent)) then (yes)
:ext = filepath.Ext(info.Name);
if (ext is audio?\n(.flac/.mp3/.aac/...)) then (yes)
:Format = audioExtensions[ext];
:AudioFileCount = 1;
:TotalAudioSize = info.Length;
else (no)
:Format = unknown;
endif
else (multi-file torrent)
:Iterate all files in torrent;
repeat
:file = next torrent file;
:ext = filepath.Ext(file.Path);
if (ext is audio?) then (yes)
:formatCounts[ext]++;
:formatSizes[ext] += file.Length;
:TrackNames += cleanTrackName(file);
note right
Strip leading "01. " or "1 - "
from filename
end note
elseif (ext is .jpg/.jpeg/.png?) then (yes)
:HasCoverArt = true;
elseif (ext is .cue?) then (yes)
:HasCueSheet = true;
elseif (ext is .log?) then (yes)
:HasRipLog = true;
endif
repeat while (more files?)
:Format = dominant format\n(most audio files);
:AudioFileCount = count of dominant;
:TotalAudioSize = sum of dominant;
endif
if (HasRipLog?) then (yes)
:Source = CD;
note right
.log file = EAC/XLD rip log
implies CD source
end note
endif
if (TrackCount == 0?) then (yes)
:TrackCount = AudioFileCount;
endif
endif
endif
}
partition "2c. Fill from Title (torrent name)" #f0f0f0 {
label TitleParsing
:title = info.Name (or item.Title for fallback);
if (title matches\n"(\\d{2,3})\\s*kbps"?) then (yes)
:Bitrate = matched value + " kbps";
endif
:Try hi-res patterns (in order):;
note right
1. "24Bit-96kHz" / "24 Bit / 48 kHz"
2. "FLAC 24-96" / "Flac 24-44"
3. "24Bit" (bit depth only)
First match wins.
end note
if (Hi-res pattern matched?) then (yes)
if (BitDepth still 0?) then (yes)
:BitDepth = matched group 1;
endif
if (SampleRate still 0\nand group 2 exists?) then (yes)
:SampleRate = matched × 1000;
endif
endif
if (title matches\n"\\[(CD|WEB|Vinyl|...)\\]"\nand Source still unknown?) then (yes)
:Source = matched value;
note right
CD, WEB, Vinyl/LP,
Cassette/MC, DVD,
Blu-Ray
end note
endif
if (title matches\nrip type pattern?) then (yes)
:RipType = matched value;
note right
vinyl rip, SACD-R,
HDCD, DSD, tape rip
end note
endif
}
:ParsedSuccessfully = (Artist != "" && Album != "");
if (not ParsedSuccessfully?) then (yes)
:ParseErrors += "missing artist or album";
endif
:Return Release;
stop
}
partition "3. Title-Only Parse (fallback)" #fff3e0 {
label TitleOnlyParse
:r = Release{RawTitle: item.Title};
note right
No torrent data available.
No InfoHash. No file analysis.
No TrackNames. No cover/cue/log.
Format stays **unknown**.
end note
goto TitleParsing
}
@enduml
+56 -12
View File
@@ -2,9 +2,26 @@ module homelab.lan/music-agregator
go 1.26.2
require github.com/rs/zerolog v1.35.1
require (
github.com/anacrolix/torrent v1.61.0
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3
github.com/jackc/pgx/v5 v5.9.2
github.com/mewkiz/flac v1.0.13
github.com/prometheus/client_golang v1.23.2
github.com/riverqueue/river v0.35.1
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/RoaringBitmap/roaring v1.2.3 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // indirect
@@ -21,7 +38,6 @@ require (
github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 // indirect
github.com/anacrolix/torrent v1.61.0 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
@@ -29,35 +45,62 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.2.2 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.3.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.6 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
@@ -76,29 +119,31 @@ require (
github.com/pion/webrtc/v4 v4.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/riverqueue/river v0.35.1 // indirect
github.com/riverqueue/river/riverdriver v0.35.1 // indirect
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1 // indirect
github.com/riverqueue/river/rivershared v0.35.1 // indirect
github.com/riverqueue/river/rivertype v0.35.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
@@ -109,7 +154,6 @@ require (
golang.org/x/sync v0.20.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/libc v1.22.3 // indirect
+148 -6
View File
@@ -2,7 +2,18 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
@@ -11,8 +22,12 @@ github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVO
github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -40,6 +55,8 @@ github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2Rd
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=
github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY=
github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
@@ -89,16 +106,38 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA=
github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
@@ -106,10 +145,16 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
@@ -130,9 +175,16 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -176,6 +228,8 @@ github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
@@ -183,6 +237,12 @@ github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -196,7 +256,10 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
@@ -205,7 +268,6 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -213,13 +275,47 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
@@ -239,6 +335,10 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
@@ -281,6 +381,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
@@ -321,20 +423,25 @@ github.com/riverqueue/river/rivershared v0.35.1 h1:XEHf7yj35p5Os5r6K08q9BVaAKsvW
github.com/riverqueue/river/rivershared v0.35.1/go.mod h1:YqVk7bZoojLsx58kyQ6ZU2FHP91HP4whVj6MTCtih/c=
github.com/riverqueue/river/rivertype v0.35.1 h1:7SfjZ3Hkr7gRjItMHAUzJBAHIqx41yS/4yjVPQVtNfM=
github.com/riverqueue/river/rivertype v0.35.1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
@@ -347,6 +454,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -359,6 +468,12 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -375,12 +490,20 @@ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
@@ -388,6 +511,8 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
@@ -405,6 +530,7 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -417,6 +543,8 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -432,6 +560,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -448,6 +578,7 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -462,17 +593,22 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -484,6 +620,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -492,8 +630,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -505,6 +641,8 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
@@ -552,6 +690,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
@@ -564,5 +704,7 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
+285
View File
@@ -0,0 +1,285 @@
package analysis
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/rs/zerolog/log"
"homelab.lan/music-agregator/internal/audio"
"homelab.lan/music-agregator/internal/database"
)
var audioExtensions = map[string]bool{
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
}
var trackNumberRegex = regexp.MustCompile(`^(\d+)[\s\-._]+`)
type ReleaseAnalyzer struct {
downloads *database.DownloadRepository
downloadFiles *database.DownloadFileRepository
albumReleases *database.AlbumReleaseRepository
trackReleases *database.TrackReleaseRepository
}
func NewReleaseAnalyzer(db *database.DB) *ReleaseAnalyzer {
return &ReleaseAnalyzer{
downloads: database.NewDownloadRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
}
}
func (a *ReleaseAnalyzer) Analyze(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
download, err := a.downloads.GetByID(ctx, downloadID)
if err != nil {
return nil, nil, fmt.Errorf("getting download: %w", err)
}
files, err := a.downloadFiles.GetByDownloadID(ctx, downloadID)
if err != nil || len(files) == 0 {
log.Info().Str("download_id", downloadID).Msg("no download files in DB, scanning filesystem")
scanned, scanErr := ScanAndHashFiles(contentPath)
if scanErr != nil {
return nil, nil, fmt.Errorf("scanning files: %w", scanErr)
}
for _, f := range scanned {
f.DownloadID = downloadID
}
if err := a.downloadFiles.CreateBatch(ctx, scanned); err != nil {
return nil, nil, fmt.Errorf("persisting scanned files: %w", err)
}
files = scanned
}
var audioFiles []*database.DownloadFile
var hasCoverArt, hasCueSheet, hasRipLog bool
for _, f := range files {
if audioExtensions["."+f.FileType] {
audioFiles = append(audioFiles, f)
}
switch f.FileType {
case "jpg", "jpeg", "png", "gif", "webp":
hasCoverArt = true
case "cue":
hasCueSheet = true
case "log":
hasRipLog = true
}
}
if len(audioFiles) == 0 {
return nil, nil, fmt.Errorf("no audio files found for download %s", downloadID)
}
var trackReleases []*database.TrackRelease
var totalSize int64
var totalDuration int
formatCounts := make(map[string]int)
var firstBitDepth, firstSampleRate, firstChannels int
var firstIsLossless bool
for i, f := range audioFiles {
fullPath := filepath.Join(contentPath, f.FilePath)
info, err := audio.Analyze(fullPath)
if err != nil {
log.Warn().Err(err).Str("path", f.FilePath).Msg("failed to analyze audio file")
info = &audio.TrackInfo{
Format: strings.ToUpper(f.FileType),
}
}
if i == 0 {
firstBitDepth = info.BitDepth
firstSampleRate = info.SampleRate
firstChannels = info.Channels
firstIsLossless = info.IsLossless
}
formatCounts[info.Format]++
totalSize += f.FileSize
totalDuration += info.DurationMs
trackNum := extractTrackNumber(f.FilePath)
title := extractTitle(f.FilePath)
tr := &database.TrackRelease{
Title: title,
TrackNumber: trackNum,
DiscNumber: 1,
Format: info.Format,
Channels: info.Channels,
FileSize: f.FileSize,
FilePath: f.FilePath,
}
if info.DurationMs > 0 {
dur := info.DurationMs
tr.DurationMs = &dur
}
if info.BitDepth > 0 {
bd := info.BitDepth
tr.BitDepth = &bd
}
if info.SampleRate > 0 {
sr := info.SampleRate
tr.SampleRate = &sr
}
if info.BitrateKbps > 0 {
br := info.BitrateKbps
tr.BitrateKbps = &br
}
trackReleases = append(trackReleases, tr)
}
dominantFormat := ""
maxCount := 0
for format, count := range formatCounts {
if count > maxCount {
dominantFormat = format
maxCount = count
}
}
var source *string
if hasRipLog {
s := "CD"
source = &s
}
release := &database.AlbumRelease{
AlbumID: download.AlbumID,
DownloadID: downloadID,
Format: dominantFormat,
Channels: firstChannels,
IsLossless: firstIsLossless,
Source: source,
TotalSize: totalSize,
TotalDurationMs: totalDuration,
TrackCount: len(audioFiles),
HasCoverArt: hasCoverArt,
HasCueSheet: hasCueSheet,
HasRipLog: hasRipLog,
Path: contentPath,
}
if firstBitDepth > 0 {
release.BitDepth = &firstBitDepth
}
if firstSampleRate > 0 {
release.SampleRate = &firstSampleRate
}
return release, trackReleases, nil
}
func (a *ReleaseAnalyzer) AnalyzeAndPersist(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
release, trackReleases, err := a.Analyze(ctx, downloadID, contentPath)
if err != nil {
return nil, nil, err
}
if err := a.albumReleases.DeleteByDownloadID(ctx, downloadID); err != nil {
log.Warn().Err(err).Str("download_id", downloadID).Msg("failed to delete existing album release")
}
if err := a.albumReleases.Create(ctx, release); err != nil {
return nil, nil, fmt.Errorf("creating album release: %w", err)
}
for _, tr := range trackReleases {
tr.AlbumReleaseID = release.ID
}
if err := a.trackReleases.CreateBatch(ctx, trackReleases); err != nil {
return nil, nil, fmt.Errorf("creating track releases: %w", err)
}
return release, trackReleases, nil
}
func extractTrackNumber(filePath string) int {
base := filepath.Base(filePath)
matches := trackNumberRegex.FindStringSubmatch(base)
if len(matches) >= 2 {
var num int
fmt.Sscanf(matches[1], "%d", &num)
return num
}
return 0
}
func extractTitle(filePath string) string {
base := filepath.Base(filePath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
name = trackNumberRegex.ReplaceAllString(name, "")
return strings.TrimSpace(name)
}
func IsAudioExtension(ext string) bool {
return audioExtensions[ext]
}
func ScanAndHashFiles(rootPath string) ([]*database.DownloadFile, error) {
var files []*database.DownloadFile
err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
ext := strings.ToLower(filepath.Ext(path))
relPath, _ := filepath.Rel(rootPath, path)
fileType := strings.TrimPrefix(ext, ".")
if fileType == "" {
return nil
}
df := &database.DownloadFile{
FilePath: relPath,
FileSize: info.Size(),
FileType: fileType,
}
if IsAudioExtension(ext) || ext == ".cue" || ext == ".log" {
hash, err := hashFile(path)
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("failed to hash file")
} else {
df.SHA256Hash = hash
now := time.Now()
df.VerifiedAt = &now
}
}
files = append(files, df)
return nil
})
return files, err
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hashing file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+35
View File
@@ -0,0 +1,35 @@
package audio
import (
"fmt"
"path/filepath"
"strings"
)
type TrackInfo struct {
Format string
BitDepth int
SampleRate int
Channels int
DurationMs int
BitrateKbps int
IsLossless bool
}
func Analyze(filePath string) (*TrackInfo, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return analyzeFLAC(filePath)
case ".mp3":
return analyzeMP3(filePath)
case ".aac", ".m4a", ".ogg", ".wav", ".ape", ".wv", ".alac":
return &TrackInfo{
Format: strings.ToUpper(strings.TrimPrefix(ext, ".")),
IsLossless: ext == ".wav" || ext == ".ape" || ext == ".wv" || ext == ".alac",
}, nil
default:
return nil, fmt.Errorf("unsupported audio format: %s", ext)
}
}
+27
View File
@@ -0,0 +1,27 @@
package audio
import (
"fmt"
"github.com/mewkiz/flac"
)
func analyzeFLAC(filePath string) (*TrackInfo, error) {
stream, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("parsing FLAC: %w", err)
}
defer stream.Close()
info := stream.Info
durationMs := int(info.NSamples * 1000 / uint64(info.SampleRate))
return &TrackInfo{
Format: "FLAC",
BitDepth: int(info.BitsPerSample),
SampleRate: int(info.SampleRate),
Channels: int(info.NChannels),
DurationMs: durationMs,
IsLossless: true,
}, nil
}
+54
View File
@@ -0,0 +1,54 @@
package audio
import (
"fmt"
"os"
"time"
"github.com/tcolgate/mp3"
)
func analyzeMP3(filePath string) (*TrackInfo, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("opening MP3: %w", err)
}
defer f.Close()
decoder := mp3.NewDecoder(f)
var frame mp3.Frame
var skipped int
var totalDuration time.Duration
var sampleRate, channels, bitrate int
var frameCount int
for {
err := decoder.Decode(&frame, &skipped)
if err != nil {
break
}
if frameCount == 0 {
sampleRate = int(frame.Header().SampleRate())
channels = channelCount(frame.Header().ChannelMode())
bitrate = int(frame.Header().BitRate()) / 1000
}
totalDuration += frame.Duration()
frameCount++
}
return &TrackInfo{
Format: "MP3",
SampleRate: sampleRate,
Channels: channels,
DurationMs: int(totalDuration.Milliseconds()),
BitrateKbps: bitrate,
IsLossless: false,
}, nil
}
func channelCount(mode mp3.FrameChannelMode) int {
if mode == mp3.SingleChannel {
return 1
}
return 2
}
+5 -4
View File
@@ -35,10 +35,11 @@ type Config struct {
} `yaml:"indexer"`
Torrent struct {
ClientType TorrentClientType `yaml:"client_type"`
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
ClientType TorrentClientType `yaml:"client_type"`
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
ContainerName string `yaml:"container_name"`
} `yaml:"torrent"`
Metadata struct {
+116
View File
@@ -0,0 +1,116 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type AlbumEvent struct {
ID string
Seq int64
WorkflowRunID string
AlbumID string
EventType string
Step string
Message string
DataJSON []byte
CreatedAt time.Time
}
type AlbumEventRepository struct {
pool *pgxpool.Pool
}
func NewAlbumEventRepository(pool *pgxpool.Pool) *AlbumEventRepository {
return &AlbumEventRepository{pool: pool}
}
func (r *AlbumEventRepository) Create(ctx context.Context, event *AlbumEvent) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO album_events (workflow_run_id, album_id, event_type, step, message, data_json)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, seq, created_at`,
event.WorkflowRunID, event.AlbumID, event.EventType, event.Step, event.Message, event.DataJSON,
).Scan(&event.ID, &event.Seq, &event.CreatedAt)
if err != nil {
return fmt.Errorf("creating album event: %w", err)
}
return nil
}
func (r *AlbumEventRepository) GetByWorkflowRun(ctx context.Context, workflowRunID string) ([]*AlbumEvent, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
FROM album_events WHERE workflow_run_id = $1 ORDER BY seq`, workflowRunID,
)
if err != nil {
return nil, fmt.Errorf("listing album events by workflow run: %w", err)
}
defer rows.Close()
var events []*AlbumEvent
for rows.Next() {
event := &AlbumEvent{}
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album event: %w", err)
}
events = append(events, event)
}
return events, nil
}
func (r *AlbumEventRepository) GetByAlbum(ctx context.Context, albumID string, afterSeq int64, limit int) ([]*AlbumEvent, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
FROM album_events WHERE album_id = $1 AND seq > $2 ORDER BY seq LIMIT $3`, albumID, afterSeq, limit,
)
if err != nil {
return nil, fmt.Errorf("listing album events by album: %w", err)
}
defer rows.Close()
var events []*AlbumEvent
for rows.Next() {
event := &AlbumEvent{}
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album event: %w", err)
}
events = append(events, event)
}
return events, nil
}
func (r *AlbumEventRepository) GetLatestSeq(ctx context.Context) (int64, error) {
var seq int64
err := r.pool.QueryRow(ctx,
`SELECT COALESCE(MAX(seq), 0) FROM album_events`,
).Scan(&seq)
if err != nil {
return 0, fmt.Errorf("getting latest album event seq: %w", err)
}
return seq, nil
}
func (r *AlbumEventRepository) GetAfterSeq(ctx context.Context, afterSeq int64) ([]*AlbumEvent, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
FROM album_events WHERE seq > $1 ORDER BY seq LIMIT 1000`, afterSeq,
)
if err != nil {
return nil, fmt.Errorf("listing album events after seq: %w", err)
}
defer rows.Close()
var events []*AlbumEvent
for rows.Next() {
event := &AlbumEvent{}
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album event: %w", err)
}
events = append(events, event)
}
return events, nil
}
@@ -0,0 +1,91 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type AlbumRelease struct {
ID string
AlbumID string
DownloadID string
Format string
BitDepth *int
SampleRate *int
Channels int
IsLossless bool
Source *string
TotalSize int64
TotalDurationMs int
TrackCount int
HasCoverArt bool
HasCueSheet bool
HasRipLog bool
Path string
CreatedAt time.Time
}
type AlbumReleaseRepository struct {
pool *pgxpool.Pool
}
func NewAlbumReleaseRepository(pool *pgxpool.Pool) *AlbumReleaseRepository {
return &AlbumReleaseRepository{pool: pool}
}
func (r *AlbumReleaseRepository) Create(ctx context.Context, ar *AlbumRelease) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO album_releases (album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, created_at`,
ar.AlbumID, ar.DownloadID, ar.Format, ar.BitDepth, ar.SampleRate, ar.Channels, ar.IsLossless, ar.Source, ar.TotalSize, ar.TotalDurationMs, ar.TrackCount, ar.HasCoverArt, ar.HasCueSheet, ar.HasRipLog, ar.Path,
).Scan(&ar.ID, &ar.CreatedAt)
if err != nil {
return fmt.Errorf("creating album release: %w", err)
}
return nil
}
func (r *AlbumReleaseRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*AlbumRelease, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
FROM album_releases WHERE album_id = $1 ORDER BY created_at DESC`, albumID,
)
if err != nil {
return nil, fmt.Errorf("listing album releases: %w", err)
}
defer rows.Close()
var releases []*AlbumRelease
for rows.Next() {
ar := &AlbumRelease{}
if err := rows.Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album release: %w", err)
}
releases = append(releases, ar)
}
return releases, nil
}
func (r *AlbumReleaseRepository) GetByDownloadID(ctx context.Context, downloadID string) (*AlbumRelease, error) {
ar := &AlbumRelease{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
FROM album_releases WHERE download_id = $1`, downloadID,
).Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt)
if err != nil {
return nil, fmt.Errorf("getting album release by download: %w", err)
}
return ar, nil
}
func (r *AlbumReleaseRepository) DeleteByDownloadID(ctx context.Context, downloadID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM album_releases WHERE download_id = $1`, downloadID)
if err != nil {
return fmt.Errorf("deleting album release by download: %w", err)
}
return nil
}
+74 -28
View File
@@ -5,24 +5,25 @@ import (
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Album struct {
ID string
ExternalID string
ArtistID string
Title string
AlbumType string
ReleaseDate *time.Time
TotalTracks int
TotalDiscs int
Label string
Genres []string
CoverURL string
IsMonitored bool
CreatedAt time.Time
UpdatedAt time.Time
ID string
ExternalID string
ArtistID string
Title string
AlbumType string
ReleaseDate *time.Time
TotalTracks int
TotalDiscs int
Label string
Genres []string
CoverURL string
MonitorState MonitorState
CreatedAt time.Time
UpdatedAt time.Time
}
type AlbumRepository struct {
@@ -35,7 +36,7 @@ func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
_, 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)
ON CONFLICT (external_id) DO UPDATE SET
title = EXCLUDED.title,
@@ -46,8 +47,13 @@ func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
label = EXCLUDED.label,
genres = EXCLUDED.genres,
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()`,
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 {
return fmt.Errorf("creating album: %w", err)
@@ -55,12 +61,52 @@ func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
return nil
}
func (r *AlbumRepository) CreateBatch(ctx context.Context, albums []*Album) error {
if len(albums) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, a := range albums {
batch.Queue(
`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)
ON CONFLICT (external_id) DO UPDATE SET
title = EXCLUDED.title,
album_type = EXCLUDED.album_type,
release_date = EXCLUDED.release_date,
total_tracks = EXCLUDED.total_tracks,
total_discs = EXCLUDED.total_discs,
label = EXCLUDED.label,
genres = EXCLUDED.genres,
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()`,
a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.MonitorState,
)
}
results := r.pool.SendBatch(ctx, batch)
defer results.Close()
for range albums {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("batch creating album: %w", err)
}
}
return nil
}
func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string) (*Album, error) {
a := &Album{}
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,
).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 {
return nil, fmt.Errorf("getting album: %w", err)
}
@@ -70,9 +116,9 @@ func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error) {
a := &Album{}
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,
).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 {
return nil, fmt.Errorf("getting album: %w", err)
}
@@ -81,7 +127,7 @@ func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error
func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([]*Album, error) {
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,
)
if err != nil {
@@ -92,7 +138,7 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([
var albums []*Album
for rows.Next() {
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)
}
albums = append(albums, a)
@@ -102,8 +148,8 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([
func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
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
FROM albums WHERE is_monitored = TRUE ORDER BY release_date DESC`,
`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 monitor_state = 'monitored' ORDER BY release_date DESC`,
)
if err != nil {
return nil, fmt.Errorf("listing monitored albums: %w", err)
@@ -113,7 +159,7 @@ func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
var albums []*Album
for rows.Next() {
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)
}
albums = append(albums, a)
@@ -121,12 +167,12 @@ func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
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,
`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 {
return fmt.Errorf("updating monitored state: %w", err)
return fmt.Errorf("updating monitor state: %w", err)
}
return nil
}
+43 -16
View File
@@ -9,15 +9,16 @@ import (
)
type Artist struct {
ID string
ExternalID string
Name string
ArtistType string
Country string
Genres []string
ImageURL string
CreatedAt time.Time
UpdatedAt time.Time
ID string
ExternalID string
Name string
ArtistType string
Country string
Genres []string
ImageURL string
MonitorState MonitorState
CreatedAt time.Time
UpdatedAt time.Time
}
type ArtistRepository struct {
@@ -30,17 +31,22 @@ func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository {
func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO artists (external_id, name, artist_type, country, genres, image_url)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (external_id) DO UPDATE SET
name = EXCLUDED.name,
artist_type = EXCLUDED.artist_type,
country = EXCLUDED.country,
genres = EXCLUDED.genres,
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()
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 {
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) {
a := &Artist{}
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,
).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 {
return nil, fmt.Errorf("getting artist: %w", err)
}
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) {
a := &Artist{}
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,
).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 {
return nil, fmt.Errorf("getting artist: %w", err)
}
+8
View File
@@ -8,6 +8,14 @@ import (
"github.com/rs/zerolog/log"
)
type MonitorState string
const (
Monitored MonitorState = "monitored"
Unmonitored MonitorState = "unmonitored"
Excluded MonitorState = "excluded"
)
type DB struct {
Pool *pgxpool.Pool
}
+93 -2
View File
@@ -16,8 +16,8 @@ type Download struct {
Quality string
State string
QbitHash string
SavePath string
ErrorMessage string
SavePath *string
ErrorMessage *string
QueuedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
@@ -86,6 +86,26 @@ func (r *DownloadRepository) SetFailed(ctx context.Context, id string, errorMsg
return nil
}
func (r *DownloadRepository) SetCancelled(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = 'cancelled', updated_at = NOW() WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("setting download cancelled: %w", err)
}
return nil
}
func (r *DownloadRepository) SetCancelledByQbitHash(ctx context.Context, hash string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = 'cancelled', updated_at = NOW() WHERE qbit_hash = $1 AND state NOT IN ('completed', 'failed', 'cancelled')`, hash,
)
if err != nil {
return fmt.Errorf("setting download cancelled by hash: %w", err)
}
return nil
}
func (r *DownloadRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*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
@@ -107,6 +127,77 @@ func (r *DownloadRepository) GetByAlbumID(ctx context.Context, albumID string) (
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) GetByID(ctx context.Context, id 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 id = $1`, id,
).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 download by id: %w", err)
}
return d, nil
}
func (r *DownloadRepository) GetLatestByAlbumIDs(ctx context.Context, albumIDs []string) (map[string]*Download, error) {
if len(albumIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx,
`SELECT DISTINCT ON (album_id) 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 album_id = ANY($1) ORDER BY album_id, created_at DESC`, albumIDs,
)
if err != nil {
return nil, fmt.Errorf("batch listing downloads: %w", err)
}
defer rows.Close()
result := make(map[string]*Download, len(albumIDs))
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)
}
result[d.AlbumID] = d
}
return result, nil
}
func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx,
@@ -0,0 +1,79 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type TrackRelease struct {
ID string
AlbumReleaseID string
TrackID *string
DownloadFileID *string
Title string
TrackNumber int
DiscNumber int
DurationMs *int
Format string
BitDepth *int
SampleRate *int
Channels int
BitrateKbps *int
FileSize int64
FilePath string
CreatedAt time.Time
}
type TrackReleaseRepository struct {
pool *pgxpool.Pool
}
func NewTrackReleaseRepository(pool *pgxpool.Pool) *TrackReleaseRepository {
return &TrackReleaseRepository{pool: pool}
}
func (r *TrackReleaseRepository) Create(ctx context.Context, tr *TrackRelease) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO track_releases (album_release_id, track_id, download_file_id, title, track_number, disc_number, duration_ms, format, bit_depth, sample_rate, channels, bitrate_kbps, file_size, file_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at`,
tr.AlbumReleaseID, tr.TrackID, tr.DownloadFileID, tr.Title, tr.TrackNumber, tr.DiscNumber, tr.DurationMs, tr.Format, tr.BitDepth, tr.SampleRate, tr.Channels, tr.BitrateKbps, tr.FileSize, tr.FilePath,
).Scan(&tr.ID, &tr.CreatedAt)
if err != nil {
return fmt.Errorf("creating track release: %w", err)
}
return nil
}
func (r *TrackReleaseRepository) CreateBatch(ctx context.Context, tracks []*TrackRelease) error {
for _, tr := range tracks {
if err := r.Create(ctx, tr); err != nil {
return err
}
}
return nil
}
func (r *TrackReleaseRepository) GetByAlbumReleaseID(ctx context.Context, albumReleaseID string) ([]*TrackRelease, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_release_id, track_id, download_file_id, title, track_number, disc_number, duration_ms, format, bit_depth, sample_rate, channels, bitrate_kbps, file_size, file_path, created_at
FROM track_releases WHERE album_release_id = $1 ORDER BY disc_number, track_number`, albumReleaseID,
)
if err != nil {
return nil, fmt.Errorf("listing track releases: %w", err)
}
defer rows.Close()
var tracks []*TrackRelease
for rows.Next() {
tr := &TrackRelease{}
if err := rows.Scan(&tr.ID, &tr.AlbumReleaseID, &tr.TrackID, &tr.DownloadFileID, &tr.Title, &tr.TrackNumber, &tr.DiscNumber, &tr.DurationMs, &tr.Format, &tr.BitDepth, &tr.SampleRate, &tr.Channels, &tr.BitrateKbps, &tr.FileSize, &tr.FilePath, &tr.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning track release: %w", err)
}
tracks = append(tracks, tr)
}
return tracks, nil
}
@@ -0,0 +1,123 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrWorkflowAlreadyRunning = fmt.Errorf("workflow already running for this album and quality")
type WorkflowRun struct {
ID string
AlbumID string
Quality string
Status string
ErrorMessage *string
StartedAt time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type WorkflowRunRepository struct {
pool *pgxpool.Pool
}
func NewWorkflowRunRepository(pool *pgxpool.Pool) *WorkflowRunRepository {
return &WorkflowRunRepository{pool: pool}
}
func (r *WorkflowRunRepository) Create(ctx context.Context, run *WorkflowRun) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO workflow_runs (album_id, quality, status) VALUES ($1, $2, 'running')
ON CONFLICT ON CONSTRAINT idx_workflow_runs_active DO NOTHING
RETURNING id, started_at, created_at, updated_at`,
run.AlbumID, run.Quality,
).Scan(&run.ID, &run.StartedAt, &run.CreatedAt, &run.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return ErrWorkflowAlreadyRunning
}
return fmt.Errorf("creating workflow run: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) SetCompleted(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`UPDATE workflow_runs SET status = 'completed', completed_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("setting workflow run completed: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) SetFailed(ctx context.Context, id string, errorMsg string) error {
_, err := r.pool.Exec(ctx,
`UPDATE workflow_runs SET status = 'failed', error_message = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2`, errorMsg, id,
)
if err != nil {
return fmt.Errorf("setting workflow run failed: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) SetCancelled(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`UPDATE workflow_runs SET status = 'cancelled', completed_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("setting workflow run cancelled: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) GetByAlbumAndQuality(ctx context.Context, albumID string, quality string) (*WorkflowRun, error) {
run := &WorkflowRun{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
FROM workflow_runs WHERE album_id = $1 AND quality = $2 AND status = 'running' LIMIT 1`, albumID, quality,
).Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting workflow run by album and quality: %w", err)
}
return run, nil
}
func (r *WorkflowRunRepository) GetRunning(ctx context.Context) ([]*WorkflowRun, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
FROM workflow_runs WHERE status = 'running' ORDER BY started_at`,
)
if err != nil {
return nil, fmt.Errorf("listing running workflow runs: %w", err)
}
defer rows.Close()
var runs []*WorkflowRun
for rows.Next() {
run := &WorkflowRun{}
if err := rows.Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning workflow run: %w", err)
}
runs = append(runs, run)
}
return runs, nil
}
func (r *WorkflowRunRepository) GetByID(ctx context.Context, id string) (*WorkflowRun, error) {
run := &WorkflowRun{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
FROM workflow_runs WHERE id = $1`, id,
).Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting workflow run by id: %w", err)
}
return run, nil
}
+260
View File
@@ -0,0 +1,260 @@
package internal
import (
"context"
"encoding/json"
"sync"
"github.com/rs/zerolog/log"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/eventbus"
)
type EventPublisher interface {
PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error
PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error
PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error
SetAlbumID(albumID string)
SetWorkflowRunID(id string)
}
type dbEventPublisher struct {
mu sync.Mutex
workflowRunID string
albumID string
quality string
events *database.AlbumEventRepository
bus *eventbus.EventBus
topic string
}
func newDBEventPublisher(albumID, quality string, events *database.AlbumEventRepository, bus *eventbus.EventBus, topic string) *dbEventPublisher {
return &dbEventPublisher{
albumID: albumID,
quality: quality,
events: events,
bus: bus,
topic: topic,
}
}
func (p *dbEventPublisher) SetAlbumID(albumID string) {
p.mu.Lock()
defer p.mu.Unlock()
p.albumID = albumID
}
func (p *dbEventPublisher) getAlbumID() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.albumID
}
func (p *dbEventPublisher) SetWorkflowRunID(id string) {
p.mu.Lock()
defer p.mu.Unlock()
p.workflowRunID = id
}
func (p *dbEventPublisher) getWorkflowRunID() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.workflowRunID
}
func (p *dbEventPublisher) PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error {
var dataJSON []byte
if data != nil {
var err error
dataJSON, err = json.Marshal(data)
if err != nil {
log.Warn().Err(err).Msg("failed to marshal status data to JSON")
dataJSON = nil
}
}
albumID := p.getAlbumID()
workflowRunID := p.getWorkflowRunID()
var seq int64
if albumID != "" {
event := &database.AlbumEvent{
WorkflowRunID: workflowRunID,
AlbumID: albumID,
EventType: "status",
Step: step.String(),
Message: msg,
DataJSON: dataJSON,
}
if err := p.events.Create(ctx, event); err != nil {
log.Error().Err(err).Msg("failed to persist status event")
} else {
seq = event.Seq
}
}
p.bus.Publish(p.topic, &eventbus.Event{
Seq: seq,
WorkflowRunID: workflowRunID,
AlbumID: albumID,
Quality: p.quality,
EventType: "status",
Step: step.String(),
Message: msg,
Data: data,
})
return nil
}
func (p *dbEventPublisher) PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error {
albumID := p.getAlbumID()
workflowRunID := p.getWorkflowRunID()
var seq int64
if albumID != "" {
event := &database.AlbumEvent{
WorkflowRunID: workflowRunID,
AlbumID: albumID,
EventType: "error",
Step: step.String(),
Message: err.Error(),
}
if dbErr := p.events.Create(ctx, event); dbErr != nil {
log.Error().Err(dbErr).Msg("failed to persist error event")
} else {
seq = event.Seq
}
}
p.bus.Publish(p.topic, &eventbus.Event{
Seq: seq,
WorkflowRunID: workflowRunID,
AlbumID: albumID,
Quality: p.quality,
EventType: "error",
Step: step.String(),
Message: err.Error(),
Data: map[string]bool{"recoverable": recoverable},
})
return nil
}
func (p *dbEventPublisher) PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error {
var dataJSON []byte
if result != nil {
var err error
dataJSON, err = json.Marshal(result)
if err != nil {
log.Warn().Err(err).Msg("failed to marshal result to JSON")
dataJSON = nil
}
}
albumID := p.getAlbumID()
workflowRunID := p.getWorkflowRunID()
var seq int64
if albumID != "" {
event := &database.AlbumEvent{
WorkflowRunID: workflowRunID,
AlbumID: albumID,
EventType: "result",
Step: pb.MonitorStep_MONITOR_STEP_COMPLETE.String(),
Message: "workflow completed",
DataJSON: dataJSON,
}
if err := p.events.Create(ctx, event); err != nil {
log.Error().Err(err).Msg("failed to persist result event")
} else {
seq = event.Seq
}
}
p.bus.Publish(p.topic, &eventbus.Event{
Seq: seq,
WorkflowRunID: workflowRunID,
AlbumID: albumID,
Quality: p.quality,
EventType: "result",
Step: pb.MonitorStep_MONITOR_STEP_COMPLETE.String(),
Message: "workflow completed",
Data: result,
})
return nil
}
type streamEventPublisher struct {
*dbEventPublisher
stream pb.MusicAgregatorService_MonitorAlbumStreamServer
}
func newStreamEventPublisher(db *dbEventPublisher, stream pb.MusicAgregatorService_MonitorAlbumStreamServer) *streamEventPublisher {
return &streamEventPublisher{
dbEventPublisher: db,
stream: stream,
}
}
func (p *streamEventPublisher) PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error {
if err := p.dbEventPublisher.PublishStatus(ctx, step, msg, data); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
status := &pb.StatusUpdate{
Step: step,
Message: msg,
}
switch v := data.(type) {
case *pb.StreamAlbumInfo:
status.Data = &pb.StatusUpdate_AlbumInfo{AlbumInfo: v}
case *pb.TorrentList:
status.Data = &pb.StatusUpdate_Torrents{Torrents: v}
case *pb.ReleaseInfo:
status.Data = &pb.StatusUpdate_ReleaseInfo{ReleaseInfo: v}
}
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
Message: &pb.MonitorAlbumStreamResponse_Status{Status: status},
})
}
func (p *streamEventPublisher) PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error {
if dbErr := p.dbEventPublisher.PublishError(ctx, step, err, recoverable); dbErr != nil {
return dbErr
}
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
Message: &pb.MonitorAlbumStreamResponse_Error{
Error: &pb.ErrorUpdate{
FailedStep: step,
Message: err.Error(),
Recoverable: recoverable,
},
},
})
}
func (p *streamEventPublisher) PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error {
if err := p.dbEventPublisher.PublishResult(ctx, result); err != nil {
return err
}
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
Message: &pb.MonitorAlbumStreamResponse_Result{Result: result},
})
}
+116
View File
@@ -0,0 +1,116 @@
package eventbus
import "sync"
type Event struct {
Seq int64
WorkflowRunID string
AlbumID string
Quality string
EventType string
Step string
Message string
Data interface{}
}
type Subscription struct {
Ring *RingBuffer[*Event]
C chan struct{}
done chan struct{}
once sync.Once
}
type EventBus struct {
mu sync.RWMutex
topics map[string]map[*Subscription]struct{}
global map[*Subscription]struct{}
}
func New() *EventBus {
return &EventBus{
topics: make(map[string]map[*Subscription]struct{}),
global: make(map[*Subscription]struct{}),
}
}
func (b *EventBus) Publish(topic string, event *Event) {
b.mu.RLock()
defer b.mu.RUnlock()
if subs, ok := b.topics[topic]; ok {
for sub := range subs {
sub.Ring.Push(event)
select {
case sub.C <- struct{}{}:
default:
}
}
}
for sub := range b.global {
sub.Ring.Push(event)
select {
case sub.C <- struct{}{}:
default:
}
}
}
func (b *EventBus) Subscribe(topic string) (*Subscription, func()) {
sub := &Subscription{
Ring: NewRingBuffer[*Event](256),
C: make(chan struct{}, 1),
done: make(chan struct{}),
}
b.mu.Lock()
if b.topics[topic] == nil {
b.topics[topic] = make(map[*Subscription]struct{})
}
b.topics[topic][sub] = struct{}{}
b.mu.Unlock()
cleanup := func() {
sub.once.Do(func() {
b.mu.Lock()
delete(b.topics[topic], sub)
if len(b.topics[topic]) == 0 {
delete(b.topics, topic)
}
b.mu.Unlock()
close(sub.done)
})
}
return sub, cleanup
}
func (b *EventBus) SubscribeGlobal() (*Subscription, func()) {
sub := &Subscription{
Ring: NewRingBuffer[*Event](256),
C: make(chan struct{}, 1),
done: make(chan struct{}),
}
b.mu.Lock()
b.global[sub] = struct{}{}
b.mu.Unlock()
cleanup := func() {
sub.once.Do(func() {
b.mu.Lock()
delete(b.global, sub)
b.mu.Unlock()
close(sub.done)
})
}
return sub, cleanup
}
func (b *EventBus) HasTopic(topic string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
_, ok := b.topics[topic]
return ok
}
+168
View File
@@ -0,0 +1,168 @@
package eventbus
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEventBus_PublishSubscribe(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("test-topic")
defer cleanup()
event := &Event{Seq: 1, EventType: "status", Message: "hello"}
bus.Publish("test-topic", event)
got, ok := sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got)
}
func TestEventBus_MultipleSubscribers(t *testing.T) {
bus := New()
sub1, cleanup1 := bus.Subscribe("topic")
defer cleanup1()
sub2, cleanup2 := bus.Subscribe("topic")
defer cleanup2()
sub3, cleanup3 := bus.Subscribe("topic")
defer cleanup3()
event := &Event{Seq: 1, EventType: "status"}
bus.Publish("topic", event)
got1, ok := sub1.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got1)
got2, ok := sub2.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got2)
got3, ok := sub3.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got3)
}
func TestEventBus_GlobalSubscriber(t *testing.T) {
bus := New()
sub, cleanup := bus.SubscribeGlobal()
defer cleanup()
bus.Publish("topic-a", &Event{Seq: 1})
bus.Publish("topic-b", &Event{Seq: 2})
bus.Publish("topic-c", &Event{Seq: 3})
got, ok := sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(1), got.Seq)
got, ok = sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(2), got.Seq)
got, ok = sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(3), got.Seq)
}
func TestEventBus_TopicIsolation(t *testing.T) {
bus := New()
subA, cleanupA := bus.Subscribe("topic-a")
defer cleanupA()
bus.Publish("topic-b", &Event{Seq: 1})
_, ok := subA.Ring.Pop()
assert.False(t, ok)
}
func TestEventBus_Notification(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("topic")
defer cleanup()
bus.Publish("topic", &Event{Seq: 1})
select {
case <-sub.C:
case <-time.After(100 * time.Millisecond):
t.Fatal("expected notification on channel")
}
}
func TestEventBus_Unsubscribe(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("topic")
bus.Publish("topic", &Event{Seq: 1})
_, ok := sub.Ring.Pop()
require.True(t, ok)
cleanup()
bus.Publish("topic", &Event{Seq: 2})
_, ok = sub.Ring.Pop()
assert.False(t, ok)
}
func TestEventBus_SlowSubscriber(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("topic")
defer cleanup()
for i := 0; i < 500; i++ {
bus.Publish("topic", &Event{Seq: int64(i)})
}
assert.Equal(t, 256, sub.Ring.Len())
first, ok := sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(244), first.Seq)
}
func TestEventBus_HasTopic(t *testing.T) {
bus := New()
assert.False(t, bus.HasTopic("topic"))
sub, cleanup := bus.Subscribe("topic")
_ = sub
assert.True(t, bus.HasTopic("topic"))
cleanup()
assert.False(t, bus.HasTopic("topic"))
}
func TestEventBus_ConcurrentPublishSubscribe(t *testing.T) {
bus := New()
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
sub, cleanup := bus.Subscribe("topic")
defer cleanup()
for j := 0; j < 100; j++ {
sub.Ring.Pop()
}
}(i)
}
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
bus.Publish("topic", &Event{Seq: int64(id*100 + j)})
}
}(i)
}
wg.Wait()
}
+56
View File
@@ -0,0 +1,56 @@
package eventbus
import "sync"
type RingBuffer[T any] struct {
mu sync.Mutex
buf []T
head int
tail int
count int
cap int
}
func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
return &RingBuffer[T]{
buf: make([]T, capacity),
cap: capacity,
}
}
func (r *RingBuffer[T]) Push(item T) {
r.mu.Lock()
defer r.mu.Unlock()
r.buf[r.head] = item
r.head = (r.head + 1) % r.cap
if r.count == r.cap {
r.tail = (r.tail + 1) % r.cap
} else {
r.count++
}
}
func (r *RingBuffer[T]) Pop() (T, bool) {
r.mu.Lock()
defer r.mu.Unlock()
var zero T
if r.count == 0 {
return zero, false
}
item := r.buf[r.tail]
r.buf[r.tail] = zero
r.tail = (r.tail + 1) % r.cap
r.count--
return item, true
}
func (r *RingBuffer[T]) Len() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.count
}
+109
View File
@@ -0,0 +1,109 @@
package eventbus
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRingBuffer_PushPop(t *testing.T) {
ring := NewRingBuffer[int](5)
ring.Push(1)
ring.Push(2)
ring.Push(3)
v, ok := ring.Pop()
require.True(t, ok)
assert.Equal(t, 1, v)
v, ok = ring.Pop()
require.True(t, ok)
assert.Equal(t, 2, v)
v, ok = ring.Pop()
require.True(t, ok)
assert.Equal(t, 3, v)
}
func TestRingBuffer_Empty(t *testing.T) {
ring := NewRingBuffer[int](5)
v, ok := ring.Pop()
assert.False(t, ok)
assert.Equal(t, 0, v)
}
func TestRingBuffer_OverwriteOldest(t *testing.T) {
ring := NewRingBuffer[int](4)
ring.Push(1)
ring.Push(2)
ring.Push(3)
ring.Push(4)
ring.Push(5)
ring.Push(6)
var values []int
for {
v, ok := ring.Pop()
if !ok {
break
}
values = append(values, v)
}
assert.Equal(t, []int{3, 4, 5, 6}, values)
}
func TestRingBuffer_Len(t *testing.T) {
ring := NewRingBuffer[int](5)
assert.Equal(t, 0, ring.Len())
ring.Push(1)
assert.Equal(t, 1, ring.Len())
ring.Push(2)
ring.Push(3)
assert.Equal(t, 3, ring.Len())
ring.Pop()
assert.Equal(t, 2, ring.Len())
ring.Push(4)
ring.Push(5)
ring.Push(6)
ring.Push(7)
assert.Equal(t, 5, ring.Len())
ring.Push(8)
assert.Equal(t, 5, ring.Len())
}
func TestRingBuffer_Concurrent(t *testing.T) {
ring := NewRingBuffer[int](100)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
ring.Push(id*100 + j)
}
}(i)
}
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 500; i++ {
ring.Pop()
}
}()
wg.Wait()
}
+3 -5
View File
@@ -8,7 +8,7 @@ import (
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
"homelab.lan/music-agregator/internal/release"
"homelab.lan/music-agregator/internal/tracker/rutracker"
"homelab.lan/music-agregator/internal/tracker"
)
type SearchResult struct {
@@ -90,15 +90,13 @@ func (sr *SearchResponse) ToProto() *pb.SearchResponse {
return &pb.SearchResponse{Result: pbItems}
}
var (
rutrackerParserFactory = rutracker.NewRuTrackerParserFactory()
)
var genericParser = tracker.NewGenericParser()
func (sr *SearchResult) ToSearchResponse() *SearchResponse {
var items []*SearchItemResult
for _, item := range sr.Items {
rel := rutrackerParserFactory.GetParser(item.Categories).Parse(item.Title)
rel := genericParser.Parse(item.Title)
log.Trace().
Str("tracker", item.JackettIndexer.ID).
+4
View File
@@ -11,6 +11,10 @@ import (
"homelab.lan/music-agregator/internal/config"
)
type Searcher interface {
Search(query string, limit int32, indexer string) (*SearchResponse, error)
}
type IndexerService struct {
indexer Indexer
}
+145 -26
View File
@@ -14,6 +14,7 @@ type MetadataService struct {
client metadataPb.MetadataServiceClient
artists *database.ArtistRepository
albums *database.AlbumRepository
tracks *database.TrackRepository
}
func NewMetadataService(client metadataPb.MetadataServiceClient, db *database.DB) *MetadataService {
@@ -21,6 +22,7 @@ func NewMetadataService(client metadataPb.MetadataServiceClient, db *database.DB
client: client,
artists: database.NewArtistRepository(db.Pool),
albums: database.NewAlbumRepository(db.Pool),
tracks: database.NewTrackRepository(db.Pool),
}
}
@@ -35,22 +37,82 @@ func (s *MetadataService) GetAlbum(ctx context.Context, albumID string) (*metada
album := resp.GetAlbum()
if _, err := s.albums.GetByExternalID(ctx, album.GetId()); err != nil {
s.persistArtist(ctx, album)
s.persistAlbum(ctx, album)
s.PersistArtist(ctx, album, database.Monitored)
s.PersistAlbum(ctx, album, database.Monitored)
}
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) SearchArtists(ctx context.Context, query string, limit, offset int32) (*metadataPb.SearchArtistsResponse, error) {
resp, err := s.client.SearchArtists(ctx, &metadataPb.SearchArtistsRequest{
Query: query,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, fmt.Errorf("searching artists: %w", err)
}
return resp, nil
}
func (s *MetadataService) GetArtistAlbumsWithPagination(ctx context.Context, artistID string, limit, offset int32) (*metadataPb.GetArtistAlbumsResponse, error) {
resp, err := s.client.GetArtistAlbums(ctx, &metadataPb.GetArtistAlbumsRequest{
ArtistId: artistID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, fmt.Errorf("fetching artist albums: %w", err)
}
return resp, 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) {
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) {
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 {
return
}
@@ -62,28 +124,68 @@ func (s *MetadataService) persistArtist(ctx context.Context, album *metadataPb.A
}
err := s.artists.Create(ctx, &database.Artist{
ExternalID: artist.GetId(),
Name: artist.GetName(),
ArtistType: artist.GetArtistType(),
Country: artist.GetCountry(),
Genres: genres,
ImageURL: artist.GetImageUrl(),
ExternalID: artist.GetId(),
Name: artist.GetName(),
ArtistType: artist.GetArtistType(),
Country: artist.GetCountry(),
Genres: genres,
ImageURL: artist.GetImageUrl(),
MonitorState: state,
})
if err != nil {
log.Warn().Err(err).Str("name", artist.GetName()).Msg("failed to persist artist")
}
}
func (s *MetadataService) persistAlbum(ctx context.Context, album *metadataPb.Album) {
artistID := ""
if len(album.GetArtists()) > 0 {
a, err := s.artists.GetByExternalID(ctx, album.GetArtists()[0].GetArtist().GetId())
if err == nil {
artistID = a.ID
func (s *MetadataService) PersistAlbum(ctx context.Context, album *metadataPb.Album, state database.MonitorState) {
s.PersistAlbumForArtist(ctx, album, "", state)
}
func (s *MetadataService) PersistAlbumsForArtist(ctx context.Context, metadataAlbums []*metadataPb.Album, artistDBID string, state database.MonitorState) {
if len(metadataAlbums) == 0 || artistDBID == "" {
return
}
dbAlbums := make([]*database.Album, 0, len(metadataAlbums))
for _, album := range metadataAlbums {
var genres []string
for _, g := range album.GetGenres() {
genres = append(genres, g.GetName())
}
labelName := ""
if album.GetLabel() != nil {
labelName = album.GetLabel().GetName()
}
dbAlbums = append(dbAlbums, &database.Album{
ExternalID: album.GetId(),
ArtistID: artistDBID,
Title: album.GetTitle(),
AlbumType: album.GetAlbumType(),
TotalTracks: int(album.GetTotalTracks()),
TotalDiscs: int(album.GetTotalDiscs()),
Label: labelName,
Genres: genres,
CoverURL: album.GetCoverUrl(),
MonitorState: state,
})
}
if err := s.albums.CreateBatch(ctx, dbAlbums); err != nil {
log.Warn().Err(err).Int("count", len(dbAlbums)).Msg("failed to batch persist albums")
}
}
func (s *MetadataService) PersistAlbumForArtist(ctx context.Context, album *metadataPb.Album, artistDBID string, state database.MonitorState) {
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")
return
}
@@ -99,18 +201,35 @@ func (s *MetadataService) persistAlbum(ctx context.Context, album *metadataPb.Al
}
err := s.albums.Create(ctx, &database.Album{
ExternalID: album.GetId(),
ArtistID: artistID,
Title: album.GetTitle(),
AlbumType: album.GetAlbumType(),
TotalTracks: int(album.GetTotalTracks()),
TotalDiscs: int(album.GetTotalDiscs()),
Label: labelName,
Genres: genres,
CoverURL: album.GetCoverUrl(),
IsMonitored: true,
ExternalID: album.GetId(),
ArtistID: artistDBID,
Title: album.GetTitle(),
AlbumType: album.GetAlbumType(),
TotalTracks: int(album.GetTotalTracks()),
TotalDiscs: int(album.GetTotalDiscs()),
Label: labelName,
Genres: genres,
CoverURL: album.GetCoverUrl(),
MonitorState: state,
})
if err != nil {
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")
}
}
}
+431
View File
@@ -0,0 +1,431 @@
package internal
import (
"context"
"fmt"
"sync"
"time"
"github.com/rs/zerolog/log"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/database"
)
var MaxPromptTimeout = 300 * time.Second
type monitorWorkflow struct {
mode pb.InteractionMode
req *pb.StartMonitorRequest
service *MusicAgregatorService
publisher EventPublisher
stream pb.MusicAgregatorService_MonitorAlbumStreamServer
decisions chan *pb.UserDecision
cancel context.CancelFunc
addedHash string
workflowRunID string
mu sync.Mutex
promptID int
}
func (w *monitorWorkflow) nextPromptID() string {
w.mu.Lock()
defer w.mu.Unlock()
w.promptID++
return fmt.Sprintf("prompt-%d", w.promptID)
}
func (w *monitorWorkflow) promptAndWait(ctx context.Context, prompt *pb.PromptForDecision) (*pb.UserDecision, error) {
if w.mode == pb.InteractionMode_INTERACTION_MODE_AUTOMATIC {
return w.defaultDecision(prompt), nil
}
if w.stream == nil {
return w.defaultDecision(prompt), nil
}
if err := w.stream.Send(&pb.MonitorAlbumStreamResponse{
Message: &pb.MonitorAlbumStreamResponse_Prompt{Prompt: prompt},
}); err != nil {
return nil, err
}
timeout := time.Duration(prompt.TimeoutSeconds) * time.Second
if timeout == 0 || timeout > MaxPromptTimeout {
timeout = MaxPromptTimeout
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
select {
case decision := <-w.decisions:
if decision.PromptId != prompt.PromptId {
return nil, status.Error(codes.InvalidArgument, "prompt_id mismatch")
}
return decision, nil
case <-timeoutCtx.Done():
return w.defaultDecision(prompt), nil
}
}
func (w *monitorWorkflow) defaultDecision(prompt *pb.PromptForDecision) *pb.UserDecision {
decision := &pb.UserDecision{PromptId: prompt.PromptId}
switch prompt.Type {
case pb.PromptType_PROMPT_TYPE_CONFIRM:
decision.Decision = &pb.UserDecision_Confirm{
Confirm: prompt.GetConfirm().GetDefaultValue(),
}
case pb.PromptType_PROMPT_TYPE_SELECT_ONE:
decision.Decision = &pb.UserDecision_SelectedId{
SelectedId: prompt.GetSelectOne().GetDefaultId(),
}
case pb.PromptType_PROMPT_TYPE_SELECT_MANY:
decision.Decision = &pb.UserDecision_SelectedIds{
SelectedIds: &pb.SelectedIds{Ids: prompt.GetSelectMany().GetDefaultIds()},
}
}
return decision
}
func (w *monitorWorkflow) receiveDecisions(ctx context.Context) {
if w.stream == nil {
return
}
for {
select {
case <-ctx.Done():
return
default:
}
msg, err := w.stream.Recv()
if err != nil {
return
}
if msg.GetCancel() != nil {
if w.cancel != nil {
w.cancel()
}
return
}
if decision := msg.GetDecision(); decision != nil {
select {
case w.decisions <- decision:
default:
}
}
}
}
func (w *monitorWorkflow) run(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, "Fetching album metadata...", nil)
album, err := w.service.metadata.GetAlbum(ctx, w.req.AlbumId)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
log.Error().Err(err).Str("album_id", w.req.AlbumId).Msg("failed to get album")
w.publisher.PublishError(ctx, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, err, false)
return err
}
artistName := ""
if len(album.GetArtists()) > 0 {
artistName = album.GetArtists()[0].GetArtist().GetName()
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA,
fmt.Sprintf("Got metadata: %s - %s", artistName, album.GetTitle()),
&pb.StreamAlbumInfo{
Artist: artistName,
Title: album.GetTitle(),
ReleaseDate: album.GetReleaseDate(),
})
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED, "Checking if already owned...", nil)
dbAlbum, _ := w.service.metadata.GetAlbumByExternalID(ctx, album.GetId())
if dbAlbum != nil {
w.publisher.SetAlbumID(dbAlbum.ID)
w.service.metadata.SetAlbumMonitorState(ctx, dbAlbum.ID, database.Monitored)
dbAlbum.MonitorState = database.Monitored
if w.workflowRunID == "" {
run := &database.WorkflowRun{AlbumID: dbAlbum.ID, Quality: w.req.Quality.String()}
if err := w.service.workflowRuns.Create(ctx, run); err != nil && err != database.ErrWorkflowAlreadyRunning {
log.Warn().Err(err).Msg("failed to create workflow run")
} else if err == nil {
w.workflowRunID = run.ID
w.publisher.SetWorkflowRunID(run.ID)
}
}
qualityStr := normalizeQuality(w.req.Quality, 0, 0)
owned, err := w.service.downloads.HasAlbumInQuality(ctx, dbAlbum.ID, w.req.Quality.String(), qualityStr)
if err == nil && owned {
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED,
fmt.Sprintf("Already owned in %s quality", qualityStr), nil)
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
PromptId: w.nextPromptID(),
Type: pb.PromptType_PROMPT_TYPE_CONFIRM,
Message: "Album already owned. Download anyway?",
Options: &pb.PromptForDecision_Confirm{
Confirm: &pb.ConfirmPrompt{
ConfirmLabel: "Download anyway",
CancelLabel: "Skip",
DefaultValue: false,
},
},
})
if err != nil {
w.publisher.PublishError(ctx, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED, err, false)
return err
}
if !decision.GetConfirm() {
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_COMPLETE, "Skipped - already owned", nil)
return w.publisher.PublishResult(ctx, w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
}
} else {
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_COMPLETE, "Already owned", nil)
return w.publisher.PublishResult(ctx, w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
}
}
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER,
fmt.Sprintf("Searching indexers for %s - %s...", artistName, album.GetTitle()), nil)
searchResult, err := w.service.searchIndexer(album, w.req.IndexerOptions.GetTracker())
if err != nil {
w.publisher.PublishError(ctx, pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER, err, true)
return err
}
parsed := w.service.parseSearchResults(searchResult, album)
if len(parsed) > 0 {
summaries := make([]*pb.TorrentSummary, len(parsed))
for i, p := range parsed {
summaries[i] = &pb.TorrentSummary{
Id: fmt.Sprintf("torrent-%d", i),
Title: p.item.Title,
Tracker: p.item.Tracker,
Seeders: int32(p.item.Seeders),
Format: p.rel.Format.String(),
Lossless: p.rel.Format.IsLossless(),
}
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS,
fmt.Sprintf("Parsed %d from %d torrents", len(parsed), len(searchResult.Items)),
&pb.TorrentList{Torrents: summaries})
} else {
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS,
fmt.Sprintf("Found %d torrents, none parseable", len(searchResult.Items)), nil)
}
if len(parsed) == 0 {
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_COMPLETE, "No parseable results found", nil)
return w.publisher.PublishResult(ctx, w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
}
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL && len(parsed) > 1 {
options := make([]*pb.SelectOption, len(parsed))
defaultIDs := make([]string, len(parsed))
for i, p := range parsed {
id := fmt.Sprintf("torrent-%d", i)
options[i] = &pb.SelectOption{
Id: id,
Label: p.item.Title,
Description: fmt.Sprintf("%s - %d seeders", p.item.Tracker, p.item.Seeders),
}
defaultIDs[i] = id
}
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
PromptId: w.nextPromptID(),
Type: pb.PromptType_PROMPT_TYPE_SELECT_MANY,
Message: "Select torrents to consider",
Options: &pb.PromptForDecision_SelectMany{
SelectMany: &pb.SelectManyPrompt{
Options: options,
DefaultIds: defaultIDs,
MinSelections: 1,
MaxSelections: int32(len(parsed)),
},
},
})
if err != nil {
w.publisher.PublishError(ctx, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS, err, false)
return err
}
selectedIDs := make(map[string]bool)
if ids := decision.GetSelectedIds(); ids != nil {
for _, id := range ids.GetIds() {
selectedIDs[id] = true
}
}
var selected []parsedItem
for i, p := range parsed {
id := fmt.Sprintf("torrent-%d", i)
if selectedIDs[id] {
selected = append(selected, p)
}
}
if len(selected) > 0 {
parsed = selected
}
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_FILTERING_QUALITY,
fmt.Sprintf("Filtering %d results by quality...", len(parsed)), nil)
filtered := filterByQuality(parsed, w.req.Quality)
if len(filtered) == 0 {
log.Warn().Str("album", album.GetTitle()).Str("quality", w.req.Quality.String()).Msg("no releases match quality filter")
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_COMPLETE, "No releases match quality filter", nil)
return w.publisher.PublishResult(ctx, w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
}
var best parsedItem
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL && len(filtered) > 1 {
options := make([]*pb.SelectOption, len(filtered))
for i, p := range filtered {
options[i] = &pb.SelectOption{
Id: fmt.Sprintf("release-%d", i),
Label: p.item.Title,
Description: fmt.Sprintf("%s - %d seeders - %s", p.item.Tracker, p.item.Seeders, p.rel.Format.String()),
}
}
bestIdx := 0
for i, p := range filtered {
if p.item.Seeders > filtered[bestIdx].item.Seeders {
bestIdx = i
}
}
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
PromptId: w.nextPromptID(),
Type: pb.PromptType_PROMPT_TYPE_SELECT_ONE,
Message: "Select release",
Options: &pb.PromptForDecision_SelectOne{
SelectOne: &pb.SelectOnePrompt{
Options: options,
DefaultId: fmt.Sprintf("release-%d", bestIdx),
},
},
})
if err != nil {
w.publisher.PublishError(ctx, pb.MonitorStep_MONITOR_STEP_SELECTING_RELEASE, err, false)
return err
}
selectedIdx := 0
if id := decision.GetSelectedId(); id != "" {
for i := range filtered {
if fmt.Sprintf("release-%d", i) == id {
selectedIdx = i
break
}
}
}
best = filtered[selectedIdx]
} else {
best = selectBestRelease(filtered)
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_SELECTING_RELEASE,
fmt.Sprintf("Selected: %s (%d seeders)", best.item.Title, best.item.Seeders),
&pb.ReleaseInfo{
InfoHash: best.rel.InfoHash,
Format: best.rel.Format.String(),
BitDepth: int32(best.rel.BitDepth),
SampleRate: int32(best.rel.SampleRate),
Seeders: int32(best.item.Seeders),
Tracker: best.item.Tracker,
})
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT,
fmt.Sprintf("Adding torrent: %s...", best.item.Title), nil)
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
PromptId: w.nextPromptID(),
Type: pb.PromptType_PROMPT_TYPE_CONFIRM,
Message: fmt.Sprintf("Add torrent '%s' to client?", best.item.Title),
Options: &pb.PromptForDecision_Confirm{
Confirm: &pb.ConfirmPrompt{
ConfirmLabel: "Add",
CancelLabel: "Skip",
DefaultValue: true,
},
},
})
if err != nil {
w.publisher.PublishError(ctx, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT, err, false)
return err
}
if !decision.GetConfirm() {
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_COMPLETE, "Skipped by user", nil)
return w.publisher.PublishResult(ctx, w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
}
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_SAVING, "Saving to database...", nil)
dbAlbum, _ = w.service.metadata.GetAlbumByExternalID(ctx, album.GetId())
if dbAlbum != nil {
w.publisher.SetAlbumID(dbAlbum.ID)
w.service.saveTorrentAndDownload(ctx, dbAlbum.ID, best)
} else {
log.Warn().Str("album_id", w.req.AlbumId).Msg("album not in DB, skipping torrent/download persistence")
}
w.addedHash = best.rel.InfoHash
if err := w.service.addToTorrentClient(best); err != nil {
w.publisher.PublishError(ctx, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT, err, true)
return err
}
w.publisher.PublishStatus(ctx, pb.MonitorStep_MONITOR_STEP_COMPLETE, "Done!", nil)
return w.publisher.PublishResult(ctx, w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, &best))
}
func (w *monitorWorkflow) cleanup(ctx context.Context) {
if w.addedHash != "" {
if err := w.service.torrentClient.DeleteTorrent(w.addedHash); err != nil {
log.Warn().Err(err).Str("hash", w.addedHash).Msg("failed to delete torrent during cancel cleanup")
}
if err := w.service.downloads.SetCancelledByQbitHash(ctx, w.addedHash); err != nil {
log.Warn().Err(err).Str("hash", w.addedHash).Msg("failed to cancel download during cleanup")
}
}
}
+292 -4
View File
@@ -2,37 +2,325 @@ package internal
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/eventbus"
"homelab.lan/music-agregator/internal/torrent"
)
type MusicAgregatorServer struct {
service *MusicAgregatorService
service *MusicAgregatorService
bus *eventbus.EventBus
registry *WorkflowRegistry
pb.UnimplementedMusicAgregatorServiceServer
}
func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorServer, error) {
service, err := NewMusicAgregatorService(cfg, riverClient, db)
func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper, db *database.DB) (*MusicAgregatorServer, error) {
service, err := NewMusicAgregatorService(cfg, riverClient, torrentClient, pathMapper, db)
if err != nil {
log.Err(err).Msg("failed to create MusicAgregatorService")
return nil, err
}
bus := eventbus.New()
return &MusicAgregatorServer{
service: service,
service: service,
bus: bus,
registry: NewWorkflowRegistry(bus),
}, nil
}
func NewMusicAgregatorServerWithService(service *MusicAgregatorService) *MusicAgregatorServer {
bus := eventbus.New()
return &MusicAgregatorServer{
service: service,
bus: bus,
registry: NewWorkflowRegistry(bus),
}
}
func NewMusicAgregatorServerWithDeps(service *MusicAgregatorService, bus *eventbus.EventBus, registry *WorkflowRegistry) *MusicAgregatorServer {
return &MusicAgregatorServer{
service: service,
bus: bus,
registry: registry,
}
}
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) {
return s.service.MonitorAlbum(ctx, req)
}
func (s *MusicAgregatorServer) MonitorAlbumStream(stream pb.MusicAgregatorService_MonitorAlbumStreamServer) error {
msg, err := stream.Recv()
if err != nil {
return err
}
startReq := msg.GetStart()
if startReq == nil {
return status.Error(codes.InvalidArgument, "first message must be StartMonitorRequest")
}
if startReq.Mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
return s.runManualWorkflow(stream, startReq)
}
return s.runAutomaticWorkflow(stream, startReq)
}
func (s *MusicAgregatorServer) runManualWorkflow(stream pb.MusicAgregatorService_MonitorAlbumStreamServer, startReq *pb.StartMonitorRequest) error {
ctx, cancel := context.WithCancel(stream.Context())
defer cancel()
albumKey := startReq.AlbumId
quality := startReq.Quality.String()
topic := albumKey + ":" + quality
dbPublisher := newDBEventPublisher("", quality, s.service.albumEvents, s.bus, topic)
publisher := newStreamEventPublisher(dbPublisher, stream)
workflow := &monitorWorkflow{
mode: startReq.Mode,
req: startReq,
service: s.service,
publisher: publisher,
stream: stream,
decisions: make(chan *pb.UserDecision, 1),
cancel: cancel,
}
go workflow.receiveDecisions(ctx)
err := workflow.run(ctx)
if ctx.Err() != nil {
cleanupCtx := context.Background()
workflow.cleanup(cleanupCtx)
if workflow.workflowRunID != "" {
s.service.workflowRuns.SetCancelled(cleanupCtx, workflow.workflowRunID)
}
} else if workflow.workflowRunID != "" {
if err != nil {
s.service.workflowRuns.SetFailed(context.Background(), workflow.workflowRunID, err.Error())
} else {
s.service.workflowRuns.SetCompleted(context.Background(), workflow.workflowRunID)
}
}
return err
}
func (s *MusicAgregatorServer) runAutomaticWorkflow(stream pb.MusicAgregatorService_MonitorAlbumStreamServer, startReq *pb.StartMonitorRequest) error {
albumKey := startReq.AlbumId
quality := startReq.Quality.String()
entry, created := s.registry.GetOrCreate(context.Background(), albumKey, quality)
sub, cleanup := s.bus.Subscribe(entry.Topic)
defer cleanup()
if created {
s.registry.WaitGroup().Add(1)
go func() {
defer s.registry.WaitGroup().Done()
defer s.registry.Remove(albumKey, quality)
publisher := newDBEventPublisher("", quality, s.service.albumEvents, s.bus, entry.Topic)
workflow := &monitorWorkflow{
mode: startReq.Mode,
req: startReq,
service: s.service,
publisher: publisher,
}
err := workflow.run(entry.Ctx)
if workflow.workflowRunID != "" {
if err != nil {
if entry.Ctx.Err() == context.Canceled {
s.service.workflowRuns.SetCancelled(context.Background(), workflow.workflowRunID)
} else {
s.service.workflowRuns.SetFailed(context.Background(), workflow.workflowRunID, err.Error())
}
} else {
s.service.workflowRuns.SetCompleted(context.Background(), workflow.workflowRunID)
}
}
}()
}
for {
select {
case <-sub.C:
for {
event, ok := sub.Ring.Pop()
if !ok {
break
}
if err := s.sendEventToStream(stream, event); err != nil {
return nil
}
if event.EventType == "result" || event.EventType == "error" {
return nil
}
}
case <-stream.Context().Done():
return nil
}
}
}
func (s *MusicAgregatorServer) sendEventToStream(stream pb.MusicAgregatorService_MonitorAlbumStreamServer, event *eventbus.Event) error {
resp := &pb.MonitorAlbumStreamResponse{}
step := pb.MonitorStep(pb.MonitorStep_value[event.Step])
switch event.EventType {
case "status":
status := &pb.StatusUpdate{Step: step, Message: event.Message}
switch v := event.Data.(type) {
case *pb.StreamAlbumInfo:
status.Data = &pb.StatusUpdate_AlbumInfo{AlbumInfo: v}
case *pb.TorrentList:
status.Data = &pb.StatusUpdate_Torrents{Torrents: v}
case *pb.ReleaseInfo:
status.Data = &pb.StatusUpdate_ReleaseInfo{ReleaseInfo: v}
}
resp.Message = &pb.MonitorAlbumStreamResponse_Status{Status: status}
case "error":
recoverable := false
if data, ok := event.Data.(map[string]bool); ok {
recoverable = data["recoverable"]
}
resp.Message = &pb.MonitorAlbumStreamResponse_Error{
Error: &pb.ErrorUpdate{FailedStep: step, Message: event.Message, Recoverable: recoverable},
}
case "result":
if result, ok := event.Data.(*pb.MonitorAlbumResponse); ok {
resp.Message = &pb.MonitorAlbumStreamResponse_Result{Result: result}
} else if event.Data != nil {
if jsonBytes, ok := event.Data.(json.RawMessage); ok {
var result pb.MonitorAlbumResponse
if err := json.Unmarshal(jsonBytes, &result); err == nil {
resp.Message = &pb.MonitorAlbumStreamResponse_Result{Result: &result}
}
}
}
}
return stream.Send(resp)
}
func (s *MusicAgregatorServer) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
return s.service.AnalyzeAlbumRelease(ctx, req)
}
func (s *MusicAgregatorServer) SearchArtists(ctx context.Context, req *pb.SearchArtistsRequest) (*pb.SearchArtistsResponse, error) {
return s.service.SearchArtists(ctx, req)
}
func (s *MusicAgregatorServer) GetArtistAlbums(ctx context.Context, req *pb.GetArtistAlbumsRequest) (*pb.GetArtistAlbumsResponse, error) {
return s.service.GetArtistAlbums(ctx, req)
}
func (s *MusicAgregatorServer) Register(server *grpc.Server) {
pb.RegisterMusicAgregatorServiceServer(server, s)
}
func (s *MusicAgregatorServer) SubscribeEvents(req *pb.SubscribeEventsRequest, stream pb.MusicAgregatorService_SubscribeEventsServer) error {
ctx := stream.Context()
sub, cleanup := s.bus.SubscribeGlobal()
defer cleanup()
if req.SinceSeq > 0 {
events, err := s.service.albumEvents.GetAfterSeq(ctx, req.SinceSeq)
if err == nil {
for _, e := range events {
if err := stream.Send(albumEventToProto(e)); err != nil {
return err
}
}
}
}
var lastSentSeq int64
if req.SinceSeq > 0 {
lastSentSeq = req.SinceSeq
}
for {
select {
case <-sub.C:
for {
event, ok := sub.Ring.Pop()
if !ok {
break
}
if event.Seq > lastSentSeq {
pbEvent := busEventToAlbumEvent(event)
if err := stream.Send(pbEvent); err != nil {
return nil
}
lastSentSeq = event.Seq
}
}
case <-ctx.Done():
return nil
}
}
}
func albumEventToProto(e *database.AlbumEvent) *pb.AlbumEvent {
return &pb.AlbumEvent{
Seq: e.Seq,
WorkflowRunId: e.WorkflowRunID,
AlbumId: e.AlbumID,
EventType: e.EventType,
Step: e.Step,
Message: e.Message,
DataJson: e.DataJSON,
TimestampMs: e.CreatedAt.UnixMilli(),
}
}
func busEventToAlbumEvent(e *eventbus.Event) *pb.AlbumEvent {
var dataJSON []byte
if e.Data != nil {
dataJSON, _ = json.Marshal(e.Data)
}
return &pb.AlbumEvent{
Seq: e.Seq,
WorkflowRunId: e.WorkflowRunID,
AlbumId: e.AlbumID,
Quality: e.Quality,
EventType: e.EventType,
Step: e.Step,
Message: e.Message,
DataJson: dataJSON,
TimestampMs: time.Now().UnixMilli(),
}
}
+650 -19
View File
@@ -6,15 +6,18 @@ import (
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/analysis"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/indexer"
@@ -34,15 +37,25 @@ type parsedItem struct {
type MusicAgregatorService struct {
config config.Config
metadata *metadata.MetadataService
indexer *indexer.IndexerService
indexer indexer.Searcher
torrentClient torrent.TorrentClient
magnetResolver *torrentParser.MagnetResolver
magnetResolver torrentParser.Resolver
riverClient *river.Client[pgx.Tx]
pathMapper *torrent.PathMapper
torrents *database.TorrentRepository
downloads *database.DownloadRepository
artists *database.ArtistRepository
downloadFiles *database.DownloadFileRepository
albumReleases *database.AlbumReleaseRepository
trackReleases *database.TrackReleaseRepository
analyzer *analysis.ReleaseAnalyzer
workflowRuns *database.WorkflowRunRepository
albumEvents *database.AlbumEventRepository
shutdownCtx context.Context
shutdownCancel context.CancelFunc
}
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], torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper, db *database.DB) (*MusicAgregatorService, error) {
idx, err := indexer.NewIndexerService(cfg, riverClient, nil)
if err != nil {
log.Err(err).Msg("failed to create IndexerService")
@@ -61,11 +74,7 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
return nil, err
}
torrentClient, err := torrent.NewTorrentClient(cfg)
if err != nil {
log.Err(err).Msg("failed to create torrent client")
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
return &MusicAgregatorService{
config: cfg,
@@ -74,17 +83,441 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
pathMapper: pathMapper,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
analyzer: analysis.NewReleaseAnalyzer(db),
workflowRuns: database.NewWorkflowRunRepository(db.Pool),
albumEvents: database.NewAlbumEventRepository(db.Pool),
shutdownCtx: ctx,
shutdownCancel: cancel,
}, nil
}
func (s *MusicAgregatorService) Close() {
if s.magnetResolver != nil {
s.magnetResolver.Close()
func NewMusicAgregatorServiceWithDeps(
metadata *metadata.MetadataService,
searcher indexer.Searcher,
torrentClient torrent.TorrentClient,
magnetResolver torrentParser.Resolver,
riverClient *river.Client[pgx.Tx],
pathMapper *torrent.PathMapper,
db *database.DB,
) *MusicAgregatorService {
ctx, cancel := context.WithCancel(context.Background())
return &MusicAgregatorService{
metadata: metadata,
indexer: searcher,
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
pathMapper: pathMapper,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
analyzer: analysis.NewReleaseAnalyzer(db),
workflowRuns: database.NewWorkflowRunRepository(db.Pool),
albumEvents: database.NewAlbumEventRepository(db.Pool),
shutdownCtx: ctx,
shutdownCancel: cancel,
}
}
func (s *MusicAgregatorService) Close() {
if s.shutdownCancel != nil {
s.shutdownCancel()
}
if closer, ok := s.magnetResolver.(interface{ Close() }); ok {
closer.Close()
}
}
func (s *MusicAgregatorService) RecoverWorkflows(ctx context.Context) {
stale, err := s.workflowRuns.GetRunning(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to query stale workflow runs for recovery")
return
}
if len(stale) == 0 {
return
}
for _, run := range stale {
downloads, err := s.downloads.GetByAlbumID(ctx, run.AlbumID)
if err != nil {
log.Error().Err(err).Str("workflow_run_id", run.ID).Msg("failed to query downloads for recovery")
s.workflowRuns.SetFailed(ctx, run.ID, "recovery: failed to query downloads")
continue
}
hasActive := false
for _, d := range downloads {
if d.State == "downloading" || d.State == "completed" || d.State == "seeding" {
hasActive = true
break
}
}
if hasActive {
s.workflowRuns.SetCompleted(ctx, run.ID)
log.Info().Str("workflow_run_id", run.ID).Str("album_id", run.AlbumID).Msg("recovered stale workflow as completed")
} else {
s.workflowRuns.SetFailed(ctx, run.ID, "server restarted during workflow")
log.Warn().Str("workflow_run_id", run.ID).Str("album_id", run.AlbumID).Msg("recovered stale workflow as failed")
}
}
}
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, len(dbArtists))
var mu sync.Mutex
g, gCtx := errgroup.WithContext(ctx)
g.SetLimit(5)
for i, a := range dbArtists {
i, a := i, a
g.Go(func() error {
albums, err := service.buildAlbumsForArtist(gCtx, a)
if err != nil {
log.Warn().Err(err).Str("artist", a.Name).Msg("failed to build album details, returning artist without albums")
}
summary := &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,
}
mu.Lock()
artists[i] = summary
mu.Unlock()
return nil
})
}
_ = g.Wait()
return &pb.GetArtistsResponse{Artists: artists}, nil
}
func (service *MusicAgregatorService) buildAlbumsForArtist(ctx context.Context, artist *database.Artist) ([]*pb.AlbumDetail, error) {
var metadataAlbums []*metadataPb.Album
var err error
for attempt := 0; attempt < 3; attempt++ {
metadataAlbums, err = service.metadata.GetArtistAlbums(ctx, artist.ExternalID)
if err == nil {
break
}
log.Warn().Err(err).Int("attempt", attempt+1).Str("artist", artist.Name).Msg("metadata GetArtistAlbums failed, retrying")
time.Sleep(time.Duration(attempt+1) * 200 * time.Millisecond)
}
if err != nil {
return nil, fmt.Errorf("fetching metadata albums: %w", err)
}
service.metadata.PersistAlbumsForArtist(ctx, metadataAlbums, 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))
albumIDs := make([]string, 0, len(dbAlbums))
for _, a := range dbAlbums {
dbAlbumsByExternalID[a.ExternalID] = a
albumIDs = append(albumIDs, a.ID)
}
downloadsByAlbumID, _ := service.downloads.GetLatestByAlbumIDs(ctx, albumIDs)
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)
if d, ok := downloadsByAlbumID[dbAlbum.ID]; ok {
detail.Download = &pb.DownloadInfo{
State: d.State,
Format: d.Format,
Quality: d.Quality,
SavePath: derefStr(d.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)
}
info, err := service.buildAlbumInfo(ctx, dbAlbum)
if err != nil {
return nil, err
}
return &pb.GetAlbumResponse{Info: info}, nil
}
func (service *MusicAgregatorService) buildAlbumInfo(ctx context.Context, dbAlbum *database.Album) (*pb.AlbumInfo, error) {
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
}
var trackReleasesByTrackID map[string]*database.TrackRelease
albumReleases, err := service.albumReleases.GetByAlbumID(ctx, dbAlbum.ID)
if err == nil && len(albumReleases) > 0 {
ar := albumReleases[0]
album.Release = &pb.AlbumReleaseDetail{
Id: ar.ID,
Format: ar.Format,
Channels: int32(ar.Channels),
IsLossless: ar.IsLossless,
TotalSize: ar.TotalSize,
TotalDurationMs: int32(ar.TotalDurationMs),
TrackCount: int32(ar.TrackCount),
HasCoverArt: ar.HasCoverArt,
HasCueSheet: ar.HasCueSheet,
HasRipLog: ar.HasRipLog,
Path: ar.Path,
}
if ar.BitDepth != nil {
album.Release.BitDepth = int32(*ar.BitDepth)
}
if ar.SampleRate != nil {
album.Release.SampleRate = int32(*ar.SampleRate)
}
if ar.Source != nil {
album.Release.Source = *ar.Source
}
trs, err := service.trackReleases.GetByAlbumReleaseID(ctx, ar.ID)
if err == nil {
trackReleasesByTrackID = make(map[string]*database.TrackRelease, len(trs))
for _, tr := range trs {
if tr.TrackID != nil {
trackReleasesByTrackID[*tr.TrackID] = tr
}
}
}
}
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 tr, ok := trackReleasesByTrackID[dbTrack.ID]; ok {
td.FilePath = tr.FilePath
td.FileSize = tr.FileSize
td.Format = tr.Format
td.Channels = int32(tr.Channels)
if tr.DurationMs != nil {
td.DurationMs = int32(*tr.DurationMs)
}
if tr.BitDepth != nil {
td.BitDepth = int32(*tr.BitDepth)
}
if tr.SampleRate != nil {
td.SampleRate = int32(*tr.SampleRate)
}
if tr.BitrateKbps != nil {
td.BitrateKbps = int32(*tr.BitrateKbps)
}
} else if df, ok := downloadFilesByTrackID[dbTrack.ID]; ok {
td.FilePath = df.FilePath
td.FileSize = df.FileSize
td.Format = df.FileType
}
}
tracks = append(tracks, td)
}
return &pb.AlbumInfo{
Album: album,
Tracks: tracks,
}, nil
}
func (service *MusicAgregatorService) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
dbAlbum, err := service.metadata.GetAlbumByID(ctx, req.GetAlbumId())
if err != nil {
return nil, fmt.Errorf("album not found: %w", err)
}
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
if err != nil || len(downloads) == 0 {
return nil, fmt.Errorf("no downloads found for album")
}
var download *database.Download
for _, d := range downloads {
if d.State == "completed" || d.State == "seeding" {
download = d
break
}
}
if download == nil {
return nil, fmt.Errorf("no completed download found for album")
}
contentPath := ""
existingRelease, err := service.albumReleases.GetByDownloadID(ctx, download.ID)
if err == nil {
contentPath = existingRelease.Path
}
if contentPath == "" && download.QbitHash != "" {
torrents, err := service.torrentClient.Find(torrent.FindOptions{Hash: download.QbitHash})
if err == nil && len(torrents) > 0 {
contentPath = torrents[0].ContentPath
if service.pathMapper != nil {
contentPath = service.pathMapper.ToHost(contentPath)
}
}
}
if contentPath == "" {
return nil, fmt.Errorf("cannot determine content path for download")
}
_, _, err = service.analyzer.AnalyzeAndPersist(ctx, download.ID, contentPath)
if err != nil {
return nil, fmt.Errorf("analyzing release: %w", err)
}
info, err := service.buildAlbumInfo(ctx, dbAlbum)
if err != nil {
return nil, err
}
return &pb.AnalyzeAlbumReleaseResponse{Info: info}, nil
}
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId())
if err != nil {
@@ -92,13 +525,16 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
return nil, err
}
dbAlbum, _ := service.metadata.GetAlbumByExternalID(ctx, req.GetAlbumId())
dbAlbum, _ := service.metadata.GetAlbumByExternalID(ctx, album.GetId())
if dbAlbum != nil {
service.metadata.SetAlbumMonitorState(ctx, dbAlbum.ID, database.Monitored)
dbAlbum.MonitorState = database.Monitored
qualityStr := normalizeQuality(req.GetQuality(), 0, 0)
owned, err := service.downloads.HasAlbumInQuality(ctx, dbAlbum.ID, req.GetQuality().String(), qualityStr)
if err == nil && owned {
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 +548,7 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
filtered := filterByQuality(parsed, req.GetQuality())
if len(filtered) == 0 {
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)
@@ -121,15 +557,14 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
return nil, err
}
dbAlbum, _ = service.metadata.GetAlbumByExternalID(ctx, album.GetId())
if dbAlbum != nil {
service.saveTorrentAndDownload(ctx, dbAlbum.ID, best)
} else {
log.Warn().Str("album_id", req.GetAlbumId()).Msg("album not in DB, skipping torrent/download persistence")
}
return &pb.MonitorAlbumResponse{
Release: buildMonitoredRelease(best),
}, nil
return service.buildMonitorAlbumResponse(ctx, album, dbAlbum, &best), nil
}
func (service *MusicAgregatorService) searchIndexer(album *metadataPb.Album, tracker string) (*indexer.SearchResponse, error) {
@@ -253,8 +688,13 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
}
}
savePath := ""
if service.pathMapper != nil {
savePath = service.pathMapper.ContainerDownloadPath()
}
if strings.HasPrefix(best.item.DownloadLink, "magnet:") {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink); err != nil {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink, savePath); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add magnet to client")
return err
}
@@ -266,7 +706,7 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
if err := service.torrentClient.AddTorrent(torrent.TorrentFile{
Filename: best.rel.Album + ".torrent",
Data: best.torrentData,
}); err != nil {
}, savePath); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add torrent to client")
return err
}
@@ -310,6 +750,12 @@ func (service *MusicAgregatorService) saveTorrentAndDownload(ctx context.Context
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{
TorrentID: savedTorrent.ID,
AlbumID: dbAlbumID,
@@ -378,6 +824,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) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
@@ -397,3 +925,106 @@ func downloadTorrentData(url string) ([]byte, error) {
return data, nil
}
func (service *MusicAgregatorService) SearchArtists(ctx context.Context, req *pb.SearchArtistsRequest) (*pb.SearchArtistsResponse, error) {
resp, err := service.metadata.SearchArtists(ctx, req.GetQuery(), req.GetLimit(), req.GetOffset())
if err != nil {
return nil, err
}
artists := make([]*pb.SearchArtistResult, len(resp.GetArtists()))
for i, a := range resp.GetArtists() {
genres := make([]string, len(a.GetGenres()))
for j, g := range a.GetGenres() {
genres[j] = g.GetName()
}
extIDs := make([]*pb.ExternalId, len(a.GetExternalIds()))
for j, e := range a.GetExternalIds() {
extIDs[j] = &pb.ExternalId{
Provider: e.GetSource(),
Id: e.GetSourceId(),
}
}
artists[i] = &pb.SearchArtistResult{
Id: a.GetId(),
Name: a.GetName(),
SortName: a.GetSortName(),
ArtistType: a.GetArtistType(),
Country: a.GetCountry(),
FormedDate: a.GetFormedDate(),
DisbandedDate: a.GetDisbandedDate(),
Description: a.GetDescription(),
ImageUrl: a.GetImageUrl(),
Genres: genres,
ExternalIds: extIDs,
}
}
return &pb.SearchArtistsResponse{
Artists: artists,
Total: resp.GetTotal(),
}, nil
}
func (service *MusicAgregatorService) GetArtistAlbums(ctx context.Context, req *pb.GetArtistAlbumsRequest) (*pb.GetArtistAlbumsResponse, error) {
resp, err := service.metadata.GetArtistAlbumsWithPagination(ctx, req.GetArtistId(), req.GetLimit(), req.GetOffset())
if err != nil {
return nil, err
}
albums := make([]*pb.AlbumResult, len(resp.GetAlbums()))
for i, a := range resp.GetAlbums() {
genres := make([]string, len(a.GetGenres()))
for j, g := range a.GetGenres() {
genres[j] = g.GetName()
}
extIDs := make([]*pb.ExternalId, len(a.GetExternalIds()))
for j, e := range a.GetExternalIds() {
extIDs[j] = &pb.ExternalId{
Provider: e.GetSource(),
Id: e.GetSourceId(),
}
}
artists := make([]*pb.AlbumArtistCredit, len(a.GetArtists()))
for j, ac := range a.GetArtists() {
artists[j] = &pb.AlbumArtistCredit{
Id: ac.GetArtist().GetId(),
Name: ac.GetArtist().GetName(),
Role: ac.GetRole(),
}
}
var label *pb.AlbumLabel
if a.GetLabel() != nil {
label = &pb.AlbumLabel{
Id: a.GetLabel().GetId(),
Name: a.GetLabel().GetName(),
Country: a.GetLabel().GetCountry(),
}
}
albums[i] = &pb.AlbumResult{
Id: a.GetId(),
Title: a.GetTitle(),
AlbumType: a.GetAlbumType(),
ReleaseDate: a.GetReleaseDate(),
Upc: a.GetUpc(),
TotalTracks: a.GetTotalTracks(),
TotalDiscs: a.GetTotalDiscs(),
CoverUrl: a.GetCoverUrl(),
Artists: artists,
Label: label,
Genres: genres,
ExternalIds: extIDs,
}
}
return &pb.GetArtistAlbumsResponse{
Albums: albums,
Total: resp.GetTotal(),
}, nil
}
+4 -2
View File
@@ -43,6 +43,8 @@ type TorrentClient interface {
Login(username string, password string) (string, error)
List() ([]TorrentInfo, error)
Find(opts FindOptions) ([]TorrentInfo, error)
AddTorrent(file TorrentFile) error
AddMagnet(magnetURI string) error
AddTorrent(file TorrentFile, savePath string) error
AddMagnet(magnetURI string, savePath string) error
DeleteTorrent(hash string) error
DefaultSavePath() (string, error)
}
+88
View File
@@ -0,0 +1,88 @@
package torrent
import (
"context"
"fmt"
"strings"
"time"
dockerclient "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
type PathMapper struct {
containerPath string
hostPath string
}
func NewPathMapper(containerName string, torrentClient TorrentClient) (*PathMapper, error) {
if containerName == "" {
savePath, err := torrentClient.DefaultSavePath()
if err != nil {
return nil, fmt.Errorf("getting default save path: %w", err)
}
log.Info().Str("path", savePath).Msg("no container configured, using direct path")
return &PathMapper{containerPath: savePath, hostPath: savePath}, nil
}
savePath, err := torrentClient.DefaultSavePath()
if err != nil {
return nil, fmt.Errorf("getting default save path: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("creating docker client: %w", err)
}
defer cli.Close()
inspect, err := cli.ContainerInspect(ctx, containerName)
if err != nil {
return nil, fmt.Errorf("inspecting container %s: %w", containerName, err)
}
hostPath := ""
for _, mount := range inspect.Mounts {
if mount.Destination == savePath {
hostPath = mount.Source
break
}
}
if hostPath == "" {
return nil, fmt.Errorf("no mount found for %s in container %s", savePath, containerName)
}
log.Info().
Str("container", containerName).
Str("container_path", savePath).
Str("host_path", hostPath).
Msg("resolved download path mapping")
return &PathMapper{containerPath: savePath, hostPath: hostPath}, nil
}
func (m *PathMapper) ToHost(containerPath string) string {
if m.containerPath == m.hostPath {
return containerPath
}
return strings.Replace(containerPath, m.containerPath, m.hostPath, 1)
}
func (m *PathMapper) ToContainer(hostPath string) string {
if m.containerPath == m.hostPath {
return hostPath
}
return strings.Replace(hostPath, m.hostPath, m.containerPath, 1)
}
func (m *PathMapper) HostDownloadPath() string {
return m.hostPath
}
func (m *PathMapper) ContainerDownloadPath() string {
return m.containerPath
}
+72 -4
View File
@@ -173,8 +173,8 @@ func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo {
return result
}
func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent file")
func (c *QbittorrentClient) AddTorrent(file TorrentFile, savePath string) error {
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Str("save_path", savePath).Msg("qbittorrent adding torrent file")
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
@@ -190,6 +190,12 @@ func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
return fmt.Errorf("writing torrent data: %w", err)
}
if savePath != "" {
if err := writer.WriteField("savepath", savePath); err != nil {
return fmt.Errorf("writing savepath field: %w", err)
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("closing multipart writer: %w", err)
}
@@ -205,14 +211,17 @@ func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
return c.doAdd(req, file.Filename)
}
func (c *QbittorrentClient) AddMagnet(magnetURI string) error {
func (c *QbittorrentClient) AddMagnet(magnetURI string, savePath string) error {
truncated := magnetURI
if len(truncated) > 80 {
truncated = truncated[:80] + "..."
}
log.Trace().Str("magnet", truncated).Msg("qbittorrent adding magnet")
log.Trace().Str("magnet", truncated).Str("save_path", savePath).Msg("qbittorrent adding magnet")
data := url.Values{"urls": {magnetURI}}
if savePath != "" {
data.Set("savepath", savePath)
}
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", strings.NewReader(data.Encode()))
if err != nil {
log.Error().Err(err).Msg("qbittorrent creating magnet add request failed")
@@ -303,3 +312,62 @@ func (t *QbittorrentListItem) toTorrentInfo() TorrentInfo {
Availability: t.Availability,
}
}
func (c *QbittorrentClient) DeleteTorrent(hash string) error {
log.Trace().Str("hash", hash).Msg("qbittorrent deleting torrent")
data := url.Values{}
data.Set("hashes", hash)
data.Set("deleteFiles", "true")
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/delete", strings.NewReader(data.Encode()))
if err != nil {
log.Error().Err(err).Msg("qbittorrent creating delete request failed")
return fmt.Errorf("creating delete request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
start := time.Now()
resp, err := c.client.Do(req)
if err != nil {
log.Error().Err(err).Msg("qbittorrent delete request failed")
return fmt.Errorf("delete request failed: %w", err)
}
defer resp.Body.Close()
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent delete response")
if resp.StatusCode != http.StatusOK {
log.Error().Int("status", resp.StatusCode).Msg("qbittorrent delete returned non-OK status")
return fmt.Errorf("delete torrent returned status %d", resp.StatusCode)
}
log.Info().Str("hash", hash).Msg("qbittorrent torrent deleted")
return nil
}
func (c *QbittorrentClient) DefaultSavePath() (string, error) {
req, err := http.NewRequest("GET", c.baseURL+"/api/v2/app/defaultSavePath", nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("requesting default save path: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("default save path returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
return strings.TrimSpace(string(body)), nil
}
+2 -9
View File
@@ -7,7 +7,6 @@ import (
"google.golang.org/grpc"
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
"homelab.lan/music-agregator/internal/config"
)
type TorrentServer struct {
@@ -15,14 +14,8 @@ type TorrentServer struct {
pb.UnimplementedTorrentServiceServer
}
func NewTorrentServer(cfg config.Config) (*TorrentServer, error) {
service, err := NewTorrentService(cfg)
if err != nil {
log.Err(err).Msg("failed to initialize TorrentService")
return nil, err
}
return &TorrentServer{service: service}, nil
func NewTorrentServer(client TorrentClient) *TorrentServer {
return &TorrentServer{service: NewTorrentService(client)}
}
func (server *TorrentServer) List(ctx context.Context, req *pb.ListRequest) (*pb.ListResponse, error) {
+3 -21
View File
@@ -11,7 +11,6 @@ import (
"github.com/rs/zerolog/log"
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
"homelab.lan/music-agregator/internal/config"
)
type TorrentService struct {
@@ -19,27 +18,10 @@ type TorrentService struct {
token string
}
func NewTorrentService(cfg config.Config) (*TorrentService, error) {
var client TorrentClient
switch cfg.Torrent.ClientType {
case config.TorrentClientQbittorrent:
client = NewQbittorrentClient(cfg.Torrent.Url)
default:
return nil, fmt.Errorf("unknown torrent client type: %s", cfg.Torrent.ClientType)
}
token, err := client.Login(cfg.Torrent.Username, cfg.Torrent.Password)
if err != nil {
return nil, fmt.Errorf("torrent client login failed: %w", err)
}
log.Info().Str("client", string(cfg.Torrent.ClientType)).Msg("torrent client connected")
func NewTorrentService(client TorrentClient) *TorrentService {
return &TorrentService{
client: client,
token: token,
}, nil
}
}
func (service *TorrentService) List(req *pb.ListRequest) (*pb.ListResponse, error) {
@@ -69,7 +51,7 @@ func (service *TorrentService) Add(req *pb.AddRequest) (*pb.AddResponse, error)
return nil, fmt.Errorf("either torrent_data or download_url must be provided")
}
if err := service.client.AddTorrent(file); err != nil {
if err := service.client.AddTorrent(file, ""); err != nil {
return nil, err
}
-5
View File
@@ -1,5 +0,0 @@
package tracker
type ParserFactory interface {
GetParser(categories []string) Parser
}
+73
View File
@@ -196,6 +196,7 @@ func (p *GenericParser) fillFromTorrent(r *release.Release, torrentData []byte)
}
p.fillFromTitle(r, info.Name)
p.deduceFromFileSize(r)
log.Trace().
Str("hash", r.InfoHash).
@@ -205,6 +206,9 @@ func (p *GenericParser) fillFromTorrent(r *release.Release, torrentData []byte)
Bool("cover", r.HasCoverArt).
Bool("cue", r.HasCueSheet).
Bool("log", r.HasRipLog).
Int("bit_depth", r.BitDepth).
Int("sample_rate", r.SampleRate).
Str("bitrate", r.Bitrate).
Msg("filled from torrent")
}
@@ -267,6 +271,75 @@ func (p *GenericParser) fillFromTitle(r *release.Release, title string) {
Msg("filled from title")
}
func (p *GenericParser) deduceFromFileSize(r *release.Release) {
if r.AudioFileCount == 0 || r.TotalAudioSize == 0 {
return
}
avgFileSize := r.TotalAudioSize / int64(r.AudioFileCount)
avgFileSizeMB := float64(avgFileSize) / (1024 * 1024)
switch {
case r.Format.IsLossless():
if r.BitDepth > 0 && r.SampleRate > 0 {
return
}
// Average FLAC file size per ~4 min track:
// 16/44.1 ≈ 25-35 MB 24/48 ≈ 40-60 MB
// 24/96 ≈ 80-120 MB 24/192 ≈ 160-240 MB
switch {
case avgFileSizeMB >= 130:
p.setIfMissing(r, 24, 192000)
case avgFileSizeMB >= 65:
p.setIfMissing(r, 24, 96000)
case avgFileSizeMB >= 38:
p.setIfMissing(r, 24, 48000)
default:
p.setIfMissing(r, 16, 44100)
}
log.Trace().
Float64("avg_file_mb", avgFileSizeMB).
Int("deduced_bit_depth", r.BitDepth).
Int("deduced_sample_rate", r.SampleRate).
Msg("deduced lossless quality from file size")
case r.Format == release.FormatMP3:
if r.Bitrate != "" {
return
}
// Average MP3 file size per ~4 min track:
// 128 kbps ≈ 3.5-4 MB 192 kbps ≈ 5-6 MB
// 256 kbps ≈ 7-8 MB 320 kbps ≈ 9-10 MB
switch {
case avgFileSizeMB >= 8.5:
r.Bitrate = "320 kbps"
case avgFileSizeMB >= 6.5:
r.Bitrate = "256 kbps"
case avgFileSizeMB >= 4.5:
r.Bitrate = "192 kbps"
default:
r.Bitrate = "128 kbps"
}
log.Trace().
Float64("avg_file_mb", avgFileSizeMB).
Str("deduced_bitrate", r.Bitrate).
Msg("deduced mp3 bitrate from file size")
}
}
func (p *GenericParser) setIfMissing(r *release.Release, bitDepth int, sampleRate int) {
if r.BitDepth == 0 {
r.BitDepth = bitDepth
}
if r.SampleRate == 0 {
r.SampleRate = sampleRate
}
}
var trackNumberPrefix = regexp.MustCompile(`^\d{1,3}[\s.\-]+`)
func cleanTrackName(name string) string {
+469 -68
View File
@@ -1,13 +1,39 @@
package tracker
import (
"os"
"bytes"
"fmt"
"testing"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
"homelab.lan/music-agregator/internal/release"
)
type testFile struct {
path string
size int64
}
func buildTorrentData(name string, files []testFile) []byte {
var buf bytes.Buffer
buf.WriteString("d8:announce35:http://tracker.example.com/announce4:infod")
if len(files) == 0 {
buf.WriteString(fmt.Sprintf("6:lengthi0e4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", len(name), name))
} else if len(files) == 1 {
buf.WriteString(fmt.Sprintf("6:lengthi%de4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", files[0].size, len(files[0].path), files[0].path))
} else {
buf.WriteString("5:filesl")
for _, f := range files {
buf.WriteString(fmt.Sprintf("d6:lengthi%de4:pathl%d:%see", f.size, len(f.path), f.path))
}
buf.WriteString(fmt.Sprintf("e4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", len(name), name))
}
buf.WriteString("ee")
return buf.Bytes()
}
func TestGenericParser_Parse(t *testing.T) {
p := NewGenericParser()
@@ -95,77 +121,452 @@ func TestGenericParser_Parse(t *testing.T) {
}
func TestGenericParser_ParseTorrent(t *testing.T) {
torrentData, err := os.ReadFile("/tmp/metallica.torrent")
if err != nil {
t.Skip("metallica.torrent not available")
}
album := &metadataPb.Album{
Title: "72 Seasons",
AlbumType: "Album",
ReleaseDate: "2023-04-14",
TotalTracks: 12,
TotalDiscs: 1,
Artists: []*metadataPb.ArtistCredit{
{Artist: &metadataPb.Artist{Name: "Metallica"}},
},
Genres: []*metadataPb.Genre{
{Name: "Thrash Metal"},
{Name: "Heavy Metal"},
},
Label: &metadataPb.Label{Name: "Blackened Recordings"},
}
p := NewGenericParser()
r := p.ParseTorrent(torrentData, album)
t.Logf("Artist: %s", r.Artist)
t.Logf("Album: %s", r.Album)
t.Logf("Year: %d", r.Year)
t.Logf("Type: %s", r.Type)
t.Logf("Genres: %v", r.Genres)
t.Logf("Format: %s", r.Format)
t.Logf("Source: %s", r.Source)
t.Logf("Label: %s", r.Label)
t.Logf("InfoHash: %s", r.InfoHash)
t.Logf("TrackCount: %d", r.TrackCount)
t.Logf("AudioFiles: %d", r.AudioFileCount)
t.Logf("AudioSize: %d bytes", r.TotalAudioSize)
t.Logf("HasCover: %v", r.HasCoverArt)
t.Logf("HasCue: %v", r.HasCueSheet)
t.Logf("HasLog: %v", r.HasRipLog)
t.Logf("TrackNames: %v", r.TrackNames)
t.Logf("Parsed OK: %v", r.ParsedSuccessfully)
t.Logf("Errors: %v", r.ParseErrors)
makeFlacFiles := func(count int, sizeMB float64) []testFile {
files := make([]testFile, count)
for i := range files {
files[i] = testFile{
path: fmt.Sprintf("%02d - Track %d.flac", i+1, i+1),
size: int64(sizeMB * 1024 * 1024),
}
}
return files
}
if r.Artist != "Metallica" {
t.Errorf("Artist = %q, want Metallica", r.Artist)
makeMp3Files := func(count int, sizeMB float64) []testFile {
files := make([]testFile, count)
for i := range files {
files[i] = testFile{
path: fmt.Sprintf("%02d - Track %d.mp3", i+1, i+1),
size: int64(sizeMB * 1024 * 1024),
}
}
return files
}
if r.Album != "72 Seasons" {
t.Errorf("Album = %q, want 72 Seasons", r.Album)
tests := []struct {
name string
torrentName string
files []testFile
album *metadataPb.Album
wantFormat release.AudioFormat
wantAudioFileCount int
wantHasCoverArt bool
wantHasCueSheet bool
wantHasRipLog bool
wantSource release.Source
wantInfoHashEmpty bool
wantBitDepth int
wantSampleRate int
wantTrackNames []string
wantArtist string
wantAlbum string
wantYear int
wantType release.Type
wantGenres []string
wantLabel string
wantParseErrors bool
}{
{
name: "flac album with cover cue log",
torrentName: "Test Artist - Test Album (2024) [FLAC]",
files: append(append(makeFlacFiles(12, 30),
testFile{path: "cover.jpg", size: 500000},
testFile{path: "album.cue", size: 2000}),
testFile{path: "rip.log", size: 5000}),
album: &metadataPb.Album{
Title: "Test Album",
AlbumType: "Album",
ReleaseDate: "2024-01-15",
TotalTracks: 12,
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Test Artist"}}},
Genres: []*metadataPb.Genre{{Name: "Rock"}},
Label: &metadataPb.Label{Name: "Test Label"},
},
wantFormat: release.FormatFLAC,
wantAudioFileCount: 12,
wantHasCoverArt: true,
wantHasCueSheet: true,
wantHasRipLog: true,
wantSource: release.SourceCD,
wantArtist: "Test Artist",
wantAlbum: "Test Album",
wantYear: 2024,
wantType: release.TypeAlbum,
wantGenres: []string{"Rock"},
wantLabel: "Test Label",
},
{
name: "mp3 album with cover",
torrentName: "Artist - MP3 Album (2023)",
files: append(makeMp3Files(10, 10),
testFile{path: "cover.jpg", size: 300000}),
album: &metadataPb.Album{
Title: "MP3 Album",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
ReleaseDate: "2023-05-20",
},
wantFormat: release.FormatMP3,
wantAudioFileCount: 10,
wantHasCoverArt: true,
wantHasCueSheet: false,
wantHasRipLog: false,
wantArtist: "Artist",
wantAlbum: "MP3 Album",
wantYear: 2023,
},
{
name: "mixed format dominant wins",
torrentName: "Mixed Format Album",
files: append(makeFlacFiles(10, 30),
testFile{path: "bonus1.mp3", size: 10485760},
testFile{path: "bonus2.mp3", size: 10485760}),
album: &metadataPb.Album{
Title: "Mixed Format Album",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
},
wantFormat: release.FormatFLAC,
wantAudioFileCount: 10,
},
{
name: "single file torrent flac",
torrentName: "Single Track.flac",
files: []testFile{{path: "Single Track.flac", size: 50 * 1024 * 1024}},
album: &metadataPb.Album{
Title: "Single Track",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
},
wantFormat: release.FormatFLAC,
wantAudioFileCount: 1,
},
{
name: "single file torrent mp3",
torrentName: "Single.mp3",
files: []testFile{{path: "Single.mp3", size: 10 * 1024 * 1024}},
album: &metadataPb.Album{
Title: "Single",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
},
wantFormat: release.FormatMP3,
wantAudioFileCount: 1,
},
{
name: "no audio files",
torrentName: "Not Music",
files: []testFile{
{path: "readme.txt", size: 1000},
{path: "image.jpg", size: 500000},
},
album: &metadataPb.Album{
Title: "Not Music",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Someone"}}},
},
wantFormat: release.FormatUnknown,
wantAudioFileCount: 0,
wantHasCoverArt: true,
},
{
name: "hires in title",
torrentName: "Artist - Album (2024) [24Bit-96kHz] FLAC",
files: makeFlacFiles(12, 100),
album: &metadataPb.Album{
Title: "Album",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
},
wantFormat: release.FormatFLAC,
wantAudioFileCount: 12,
wantBitDepth: 24,
wantSampleRate: 96000,
},
{
name: "source from title",
torrentName: "Artist - Album [WEB] FLAC",
files: makeFlacFiles(10, 30),
album: &metadataPb.Album{
Title: "Album",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
},
wantFormat: release.FormatFLAC,
wantAudioFileCount: 10,
wantSource: release.SourceWEB,
},
{
name: "track names cleaned",
torrentName: "Artist - Album",
files: []testFile{
{path: "01 - First Track.flac", size: 30 * 1024 * 1024},
{path: "02 - Second Track.flac", size: 30 * 1024 * 1024},
},
album: &metadataPb.Album{
Title: "Album",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
},
wantFormat: release.FormatFLAC,
wantAudioFileCount: 2,
wantTrackNames: []string{"First Track", "Second Track"},
},
{
name: "metadata fills release fields",
torrentName: "Test Torrent",
files: makeFlacFiles(8, 30),
album: &metadataPb.Album{
Title: "Metadata Album",
AlbumType: "EP",
ReleaseDate: "2020-06-15",
TotalTracks: 8,
TotalDiscs: 1,
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Metadata Artist"}}},
Genres: []*metadataPb.Genre{{Name: "Electronic"}, {Name: "Ambient"}},
Label: &metadataPb.Label{Name: "Metadata Label"},
},
wantFormat: release.FormatFLAC,
wantAudioFileCount: 8,
wantArtist: "Metadata Artist",
wantAlbum: "Metadata Album",
wantYear: 2020,
wantType: release.TypeEP,
wantGenres: []string{"Electronic", "Ambient"},
wantLabel: "Metadata Label",
},
{
name: "empty torrent data",
torrentName: "",
files: nil,
album: &metadataPb.Album{
Title: "Album Only",
ReleaseDate: "2022-01-01",
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist Only"}}},
},
wantFormat: release.FormatUnknown,
wantAudioFileCount: 0,
wantInfoHashEmpty: true,
wantArtist: "Artist Only",
wantAlbum: "Album Only",
wantYear: 2022,
},
{
name: "invalid torrent data",
torrentName: "invalid",
files: nil,
album: &metadataPb.Album{Title: "Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}},
wantArtist: "Artist",
wantAlbum: "Album",
wantParseErrors: true,
},
}
if r.Year != 2023 {
t.Errorf("Year = %d, want 2023", r.Year)
}
if r.Format != release.FormatFLAC {
t.Errorf("Format = %v, want FLAC", r.Format)
}
if r.AudioFileCount != 12 {
t.Errorf("AudioFileCount = %d, want 12", r.AudioFileCount)
}
if !r.HasCoverArt {
t.Error("expected HasCoverArt")
}
if !r.HasCueSheet {
t.Error("expected HasCueSheet")
}
if !r.HasRipLog {
t.Error("expected HasRipLog")
}
if r.Source != release.SourceCD {
t.Errorf("Source = %v, want CD (inferred from log)", r.Source)
}
if !r.ParsedSuccessfully {
t.Errorf("ParsedSuccessfully = false, errors: %v", r.ParseErrors)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var torrentData []byte
if tt.name == "empty torrent data" {
torrentData = nil
} else if tt.name == "invalid torrent data" {
torrentData = []byte("garbage data that is not valid bencode")
} else {
torrentData = buildTorrentData(tt.torrentName, tt.files)
}
r := p.ParseTorrent(torrentData, tt.album)
if r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if r.AudioFileCount != tt.wantAudioFileCount {
t.Errorf("AudioFileCount = %d, want %d", r.AudioFileCount, tt.wantAudioFileCount)
}
if r.HasCoverArt != tt.wantHasCoverArt {
t.Errorf("HasCoverArt = %v, want %v", r.HasCoverArt, tt.wantHasCoverArt)
}
if r.HasCueSheet != tt.wantHasCueSheet {
t.Errorf("HasCueSheet = %v, want %v", r.HasCueSheet, tt.wantHasCueSheet)
}
if r.HasRipLog != tt.wantHasRipLog {
t.Errorf("HasRipLog = %v, want %v", r.HasRipLog, tt.wantHasRipLog)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
if tt.wantInfoHashEmpty && r.InfoHash != "" {
t.Errorf("InfoHash = %q, want empty", r.InfoHash)
}
if !tt.wantInfoHashEmpty && tt.name != "invalid torrent data" && r.InfoHash == "" {
t.Error("InfoHash should not be empty")
}
if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth {
t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth)
}
if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate {
t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate)
}
if len(tt.wantTrackNames) > 0 {
if len(r.TrackNames) != len(tt.wantTrackNames) {
t.Errorf("TrackNames length = %d, want %d", len(r.TrackNames), len(tt.wantTrackNames))
} else {
for i, name := range tt.wantTrackNames {
if r.TrackNames[i] != name {
t.Errorf("TrackNames[%d] = %q, want %q", i, r.TrackNames[i], name)
}
}
}
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantAlbum != "" && r.Album != tt.wantAlbum {
t.Errorf("Album = %q, want %q", r.Album, tt.wantAlbum)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if len(tt.wantGenres) > 0 {
if len(r.Genres) != len(tt.wantGenres) {
t.Errorf("Genres length = %d, want %d", len(r.Genres), len(tt.wantGenres))
} else {
for i, g := range tt.wantGenres {
if r.Genres[i] != g {
t.Errorf("Genres[%d] = %q, want %q", i, r.Genres[i], g)
}
}
}
}
if tt.wantLabel != "" && r.Label != tt.wantLabel {
t.Errorf("Label = %q, want %q", r.Label, tt.wantLabel)
}
if tt.wantParseErrors && len(r.ParseErrors) == 0 {
t.Error("expected ParseErrors but got none")
}
})
}
}
func TestGenericParser_DeduceFromFileSize(t *testing.T) {
p := NewGenericParser()
makeFlacRelease := func(count int, avgSizeMB float64) *release.Release {
return &release.Release{
Format: release.FormatFLAC,
AudioFileCount: count,
TotalAudioSize: int64(float64(count) * avgSizeMB * 1024 * 1024),
}
}
makeMp3Release := func(count int, avgSizeMB float64) *release.Release {
return &release.Release{
Format: release.FormatMP3,
AudioFileCount: count,
TotalAudioSize: int64(float64(count) * avgSizeMB * 1024 * 1024),
}
}
tests := []struct {
name string
release *release.Release
wantBitDepth int
wantSampleRate int
wantBitrate string
}{
{
name: "flac 16/44.1 from small files",
release: makeFlacRelease(12, 30),
wantBitDepth: 16,
wantSampleRate: 44100,
},
{
name: "flac 24/48 from medium files",
release: makeFlacRelease(12, 50),
wantBitDepth: 24,
wantSampleRate: 48000,
},
{
name: "flac 24/96 from large files",
release: makeFlacRelease(12, 100),
wantBitDepth: 24,
wantSampleRate: 96000,
},
{
name: "flac 24/192 from very large files",
release: makeFlacRelease(12, 200),
wantBitDepth: 24,
wantSampleRate: 192000,
},
{
name: "title overrides heuristic",
release: &release.Release{
Format: release.FormatFLAC,
AudioFileCount: 12,
TotalAudioSize: int64(12 * 30 * 1024 * 1024),
BitDepth: 24,
SampleRate: 48000,
},
wantBitDepth: 24,
wantSampleRate: 48000,
},
{
name: "mp3 320kbps from large files",
release: makeMp3Release(12, 10),
wantBitrate: "320 kbps",
},
{
name: "mp3 128kbps from small files",
release: makeMp3Release(12, 3.5),
wantBitrate: "128 kbps",
},
{
name: "mp3 title overrides",
release: &release.Release{
Format: release.FormatMP3,
AudioFileCount: 12,
TotalAudioSize: int64(12 * 3.5 * 1024 * 1024),
Bitrate: "320 kbps",
},
wantBitrate: "320 kbps",
},
{
name: "no audio files skips deduction",
release: &release.Release{
Format: release.FormatFLAC,
AudioFileCount: 0,
TotalAudioSize: 0,
},
wantBitDepth: 0,
wantSampleRate: 0,
},
{
name: "aac files no deduction",
release: &release.Release{
Format: release.FormatAAC,
AudioFileCount: 12,
TotalAudioSize: int64(12 * 50 * 1024 * 1024),
},
wantBitDepth: 0,
wantSampleRate: 0,
wantBitrate: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p.deduceFromFileSize(tt.release)
if tt.wantBitDepth != 0 && tt.release.BitDepth != tt.wantBitDepth {
t.Errorf("BitDepth = %d, want %d", tt.release.BitDepth, tt.wantBitDepth)
}
if tt.wantSampleRate != 0 && tt.release.SampleRate != tt.wantSampleRate {
t.Errorf("SampleRate = %d, want %d", tt.release.SampleRate, tt.wantSampleRate)
}
if tt.wantBitrate != "" && tt.release.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", tt.release.Bitrate, tt.wantBitrate)
}
if tt.name == "no audio files skips deduction" || tt.name == "aac files no deduction" {
if tt.release.BitDepth != 0 || tt.release.SampleRate != 0 || tt.release.Bitrate != "" {
t.Errorf("expected no deduction, got BitDepth=%d, SampleRate=%d, Bitrate=%q",
tt.release.BitDepth, tt.release.SampleRate, tt.release.Bitrate)
}
}
})
}
}
+4
View File
@@ -11,6 +11,10 @@ import (
"github.com/rs/zerolog/log"
)
type Resolver interface {
Resolve(magnetURI string) ([]byte, error)
}
type MagnetResolver struct {
client *torrent.Client
timeout time.Duration
-7
View File
@@ -1,7 +0,0 @@
package tracker
import "homelab.lan/music-agregator/internal/release"
type Parser interface {
Parse(title string) *release.Release
}
-404
View File
@@ -1,404 +0,0 @@
package rutracker
var RockForumIDs = []int{
1698, // Зарубежный Rock (parent)
1702, // Classic Rock & Hard Rock (lossless)
1703, // Classic Rock & Hard Rock (lossy)
1704, // Progressive & Art-Rock (lossless)
1705, // Progressive & Art-Rock (lossy)
1706, // Folk-Rock (lossless)
1707, // Folk-Rock (lossy)
1708, // Pop-Rock & Soft Rock (lossless)
1709, // Pop-Rock & Soft Rock (lossy)
1710, // Instrumental Guitar Rock (lossless)
1711, // Instrumental Guitar Rock (lossy)
1712, // Rockabilly, Psychobilly, Rock'n'Roll (lossless)
1713, // Rockabilly, Psychobilly, Rock'n'Roll (lossy)
1714, // Восточноазиатский рок (lossless)
1715, // Восточноазиатский рок (lossy)
722, // Отечественный Rock, Metal (parent)
951, // Rock на языках народов xUSSR (lossless)
952, // Rock на языках народов xUSSR (lossy)
172, // Post-Punk, Shoegaze, Garage Rock, Noise Rock (lossless)
236, // Post-Punk, Shoegaze, Garage Rock, Noise Rock (lossy)
2175, // Avant-garde, Experimental Rock (lossless)
2174, // Avant-garde, Experimental Rock (lossy)
2329, // AOR (Melodic Hard Rock, Arena rock) (lossless)
2330, // AOR (Melodic Hard Rock, Arena rock) (lossy)
731, // Сборники зарубежного рока (lossless)
1799, // Сборники зарубежного рока (lossy)
737, // Rock (lossless)
738, // Rock (lossy)
}
var MetalForumIDs = []int{
1716, // Зарубежный Metal (parent)
739, // Metal (lossless)
740, // Metal (lossy)
1719, // Black (lossless)
1778, // Black (lossy)
1720, // Folk, Pagan, Viking (lossless)
798, // Folk, Pagan, Viking (lossy)
1724, // Gothic Metal (lossless)
1725, // Gothic Metal (lossy)
1726, // Heavy, Power, Progressive (lossless)
1727, // Heavy, Power, Progressive (lossy)
1728, // Thrash, Speed (lossless)
1729, // Thrash, Speed (lossy)
1730, // Grind, Brutal Death (lossless)
1731, // Grind, Brutal Death (lossy)
1779, // Death, Doom (lossless)
1780, // Death, Doom (lossy)
1796, // Avant-garde, Experimental Metal (lossless)
1797, // Avant-garde, Experimental Metal (lossy)
1815, // Sludge, Stoner, Post-Metal (lossless)
1816, // Sludge, Stoner, Post-Metal (lossy)
1766, // Зарубежный и Отечественный Metal (оцифровки)
}
var AlternativeForumIDs = []int{
1732, // Зарубежные Alternative, Punk, Independent (parent)
464, // Alternative, Punk, Independent (lossless)
463, // Alternative, Punk, Independent (lossy)
123, // Alternative, Punk, Independent (оцифровки)
1736, // Alternative & Nu-metal (lossless)
1737, // Alternative & Nu-metal (lossy)
1738, // Punk (lossless)
1739, // Punk (lossy)
1740, // Hardcore (lossless)
1741, // Hardcore (lossy)
1742, // Post-Rock (lossless)
1743, // Post-Rock (lossy)
1744, // Industrial & Post-industrial (lossless)
1745, // Industrial & Post-industrial (lossy)
1746, // Emocore, Post-hardcore, Metalcore (lossless)
1747, // Emocore, Post-hardcore, Metalcore (lossy)
1748, // Gothic Rock & Dark Folk (lossless)
1749, // Gothic Rock & Dark Folk (lossy)
1773, // Indie Rock, Indie Pop, Dream Pop, Brit-Pop (lossless)
202, // Indie Rock, Indie Pop, Dream Pop, Brit-Pop (lossy)
466, // Synthwave, Spacesynth, Dreamwave, Retrowave, Outrun (lossless)
465, // Synthwave, Spacesynth, Dreamwave, Retrowave, Outrun (lossy)
}
var PopForumIDs = []int{
2495, // Отечественная поп-музыка (parent)
2497, // Зарубежная поп-музыка (parent)
425, // Популярная музыка России и стран бывшего СССР (lossless)
424, // Популярная музыка России и стран бывшего СССР (lossy)
429, // Зарубежная поп-музыка (lossless)
428, // Зарубежная поп-музыка (lossy)
1753, // Итальянская поп-музыка (lossless)
735, // Итальянская поп-музыка (lossy)
714, // Латиноамериканская поп-музыка (lossless)
2232, // Латиноамериканская поп-музыка (lossy)
1330, // Восточноазиатская поп-музыка (lossless)
1331, // Восточноазиатская поп-музыка (lossy)
1634, // Советская эстрада, ретро, романсы (lossless)
1635, // Советская эстрада, ретро, романсы (lossy)
1361, // Популярная музыка России и стран бывшего СССР (сборники) (lossy)
1362, // Зарубежная поп-музыка (сборники) (lossy)
2270, // Easy Listening, Instrumental Pop (lossless)
2275, // Easy Listening, Instrumental Pop (lossy)
}
var ElectronicForumIDs = []int{
1807, // House, Techno, Hardcore, Hardstyle, Jumpstyle (parent)
1808, // Drum & Bass, Jungle, Breakbeat, Dubstep, IDM, Electro (parent)
1809, // Chillout, Lounge, Downtempo, Trip-Hop (parent)
1810, // Traditional Electronic, Ambient, Modern Classical, Electroacoustic, Experimental (parent)
1811, // Industrial, Noise, EBM, Dark Electro, Aggrotech, Cyberpunk, Synthpop, New Wave (parent)
1821, // Trance, Goa Trance, Psy-Trance, PsyChill, Ambient, Dub (parent)
2499, // Eurodance, Disco, Hi-NRG (parent)
797, // Electro, Electro-Freestyle, Nu Electro (lossless)
1805, // Electro, Electro-Freestyle, Nu Electro (lossy)
1857, // House (lossless)
1858, // House (lossy)
1860, // House (Singles, EPs) (lossy)
840, // House (Проморелизы, сборники) (lossy)
1825, // Techno (lossless)
1826, // Techno (lossy)
1828, // Techno (Singles, EPs) (lossy)
1829, // Hardcore, Hardstyle, Jumpstyle (lossless)
1830, // Hardcore, Hardstyle, Jumpstyle (lossy)
1831, // Hardcore, Hardstyle, Jumpstyle (vinyl, web)
1832, // Drum & Bass, Jungle (lossless)
1833, // Drum & Bass, Jungle (lossy)
1836, // Breakbeat (lossless)
1837, // Breakbeat (lossy)
1839, // Dubstep (lossless)
454, // Dubstep (lossy)
1840, // IDM (lossless)
1841, // IDM (lossy)
2229, // IDM Discography & Collections (lossy)
1818, // Trance (lossless)
1819, // Trance (lossy)
1847, // Trance (Singles, EPs) (lossy)
1844, // Goa Trance, Psy-Trance (lossless)
1822, // Goa Trance, Psy-Trance (lossy)
1894, // PsyChill, Ambient, Dub (lossless)
1895, // PsyChill, Ambient, Dub (lossy)
1861, // Chillout, Lounge, Downtempo (lossless)
1862, // Chillout, Lounge, Downtempo (lossy)
1945, // Trip Hop, Abstract Hip-Hop (lossless)
1944, // Trip Hop, Abstract Hip-Hop (lossy)
1864, // Traditional Electronic, Ambient (lossless)
1865, // Traditional Electronic, Ambient (lossy)
1871, // Modern Classical, Electroacoustic (lossless)
1867, // Modern Classical, Electroacoustic (lossy)
1869, // Experimental (lossless)
1873, // Experimental (lossy)
1866, // Darkwave, Neoclassical, Ethereal, Dungeon Synth (lossless)
406, // Darkwave, Neoclassical, Ethereal, Dungeon Synth (lossy)
1868, // EBM, Dark Electro, Aggrotech (lossless)
1875, // EBM, Dark Electro, Aggrotech (lossy)
1877, // Industrial, Noise (lossless)
1878, // Industrial, Noise (lossy)
1880, // Synthpop, Futurepop, New Wave, Electropop (lossless)
1881, // Synthpop, Futurepop, New Wave, Electropop (lossy)
1907, // Cyberpunk, 8-bit, Chiptune (lossy & lossless)
2500, // Disco, Italo-Disco, Euro-Disco, Hi-NRG (lossless)
2501, // Disco, Italo-Disco, Euro-Disco, Hi-NRG (lossy)
2502, // Eurodance, Euro-House, Technopop (lossless)
2503, // Eurodance, Euro-House, Technopop (lossy)
2504, // Eurodance, Euro-House, Technopop (сборники) (lossy)
2505, // Disco, Italo-Disco, Euro-Disco, Hi-NRG (сборники) (lossy)
}
var HipHopForumIDs = []int{
408, // Рэп, Хип-Хоп, R'n'B (parent)
441, // Отечественный Рэп, Хип-Хоп (lossy)
1486, // Отечественный Рэп, Хип-Хоп, R'n'B (lossless)
446, // Зарубежный Рэп, Хип-Хоп (lossy)
909, // Зарубежный Рэп, Хип-Хоп (lossless)
1665, // Зарубежный R'n'B (lossless)
1172, // Зарубежный R'n'B (lossy)
1173, // Отечественный R'n'B (lossy)
2283, // Funk, Soul, R&B (lossless)
}
var JazzForumIDs = []int{
2267, // Зарубежный джаз (parent)
2269, // Отечественный джаз и блюз (parent)
2277, // Early Jazz, Swing, Gypsy (lossless)
2278, // Bop (lossless)
2279, // Mainstream Jazz, Cool (lossless)
2280, // Jazz Fusion (lossless)
2281, // World Fusion, Ethnic Jazz (lossless)
2282, // Avant-Garde Jazz, Free Improvisation (lossless)
2284, // Smooth, Jazz-Pop (lossless)
2285, // Vocal Jazz (lossless)
2286, // Сборники зарубежного джаза (lossless)
2287, // Зарубежный джаз (lossy)
2353, // Modern Creative, Third Stream (lossless)
1947, // Nu Jazz, Acid Jazz, Future Jazz (lossless)
1946, // Nu Jazz, Acid Jazz, Future Jazz (lossy)
2297, // Отечественный джаз (lossless)
2295, // Отечественный джаз (lossy)
}
var BluesForumIDs = []int{
2268, // Зарубежный блюз (parent)
2290, // Roots, Pre-War Blues, Early R&B, Gospel (lossless)
2292, // Blues-rock (lossless)
2293, // Blues (Texas, Chicago, Modern and Others) (lossless)
2288, // Зарубежный блюз (lossy)
2289, // Зарубежный блюз (сборники; Tribute VA) (lossless)
2296, // Отечественный блюз (lossless)
2298, // Отечественный блюз (lossy)
}
var ClassicalForumIDs = []int{
409, // Классическая и современная академическая музыка (parent)
556, // Вокальная музыка (lossless)
557, // Оркестровая музыка (lossless)
558, // Камерная инструментальная музыка (lossless)
793, // Сольная инструментальная музыка (lossless)
794, // Опера (lossless)
560, // Полные собрания сочинений и многодисковые издания (lossless)
436, // Полные собрания сочинений и многодисковые издания (lossy)
2307, // Хоровая музыка (lossless)
2308, // Концерт для инструмента с оркестром (lossless)
2309, // Вокальная и хоровая музыка (lossy)
2310, // Оркестровая музыка (lossy)
2311, // Камерная и сольная инструментальная музыка (lossy)
969, // Классика в современной обработке, Classical Crossover (lossy и lossless)
}
var FolkForumIDs = []int{
1125, // Фольклор, Народная и Этническая музыка (parent)
1127, // New Age & Meditative (lossless)
1126, // New Age & Meditative (lossy)
1129, // Этническая музыка Сибири, Средней и Восточной Азии (lossless)
1128, // Этническая музыка Сибири, Средней и Восточной Азии (lossy)
1131, // Восточноевропейский фолк (lossless)
1130, // Восточноевропейский фолк (lossy)
1133, // Западноевропейский фолк (lossless)
1132, // Западноевропейский фолк (lossy)
1135, // Фламенко и акустическая гитара (lossless)
1134, // Фламенко и акустическая гитара (lossy)
1137, // Country, Bluegrass (lossless)
1136, // Country, Bluegrass (lossy)
1138, // Этническая музыка Австралии, Тихого и Индийского океанов (lossy и lossless)
1282, // Фольклорная, Народная, Эстрадная музыка Кавказа и Закавказья (lossy и lossless)
2085, // Этническая музыка Африки и Ближнего Востока (lossless)
1283, // Этническая музыка Африки и Ближнего Востока (lossy)
1285, // Этническая музыка Северной и Южной Америки (lossless)
1284, // Этническая музыка Северной и Южной Америки (lossy)
1856, // Этническая музыка Индии (lossy)
2430, // Этническая музыка Индии (lossless)
2084, // Klezmer и Еврейский фольклор (lossy и lossless)
}
var ReggaeForumIDs = []int{
1760, // Reggae, Ska, Dub (parent)
1764, // Rocksteady, Early Reggae, Ska-Jazz, Trad.Ska (lossy и lossless)
1765, // Reggae (lossy)
1768, // Reggae, Dancehall, Dub (lossless)
1769, // Ska-Punk, Ska-Core (lossy)
1770, // Dancehall, Raggamuffin (lossy)
1771, // Dub (lossy)
1772, // Отечественный Reggae, Ska, Dub (lossy и lossless)
1774, // Ska, Ska-Punk, Ska-Jazz (lossless)
1767, // 3rd Wave Ska (lossy)
2233, // Reggae, Ska, Dub (компиляции) (lossy и lossless)
}
var SoundtrackForumIDs = []int{
416, // Саундтреки, караоке и мюзиклы (parent)
691, // Саундтреки к отечественным фильмам (lossless)
469, // Саундтреки к отечественным фильмам (lossy)
786, // Саундтреки к зарубежным фильмам (lossless)
785, // Саундтреки к зарубежным фильмам (lossy)
784, // Саундтреки к играм (lossless)
783, // Саундтреки к играм (lossy)
715, // Саундтреки к мультфильмам (lossy и lossless)
1631, // Саундтреки к сериалам (lossless)
1499, // Саундтреки к сериалам (lossy)
1388, // Саундтреки к аниме (lossless)
282, // Саундтреки к аниме (lossy)
796, // Неофициальные саундтреки к фильмам и сериалам (lossy)
2331, // Неофициальные саундтреки к играм (lossy)
2431, // Аранжировки музыки из игр (lossy и lossless)
880, // Мюзикл (lossy и lossless)
}
var ShansonForumIDs = []int{
1215, // Шансон, Авторская и Военная песня (parent)
1220, // Отечественный шансон (lossless)
1221, // Отечественный шансон (lossy)
1452, // Зарубежный шансон (lossless)
1219, // Зарубежный шансон (lossy)
1216, // Военная песня, марши (lossless)
1223, // Военная песня, марши (lossy)
1224, // Авторская песня (lossless)
1225, // Авторская песня (lossy)
1226, // Менестрели и ролевики (lossy и lossless)
1334, // Сборники отечественного шансона (lossy)
}
var HiResForumIDs = []int{
1299, // Hi-Res stereo и многоканальная музыка (parent)
1755, // Рок-музыка (Hi-Res stereo)
1757, // Рок-музыка (многоканальная музыка)
1884, // Классика и классика в современной обработке (Hi-Res stereo)
1164, // Классика и классика в современной обработке (многоканальная музыка)
1885, // Поп-музыка (Hi-Res stereo)
1163, // Поп-музыка (многоканальная музыка)
1893, // Электронная музыка (Hi-Res stereo)
1890, // Электронная музыка (многоканальная музыка)
2302, // Джаз и Блюз (Hi-Res stereo)
2303, // Джаз и Блюз (многоканальная музыка)
1397, // Саундтреки (Hi-Res stereo и многоканальная музыка)
2512, // Музыка разных жанров (Hi-Res stereo и многоканальная музыка)
2513, // New Age, Relax, Meditative & Flamenco (Hi-Res stereo и многоканальная музыка)
1170, // Конверсии SACD
453, // Конверсии Quadraphonic
1759, // Конверсии Blu-Ray, ADVD и DVD-Audio
1852, // Апмиксы-Upmixes
860, // Неофициальные конверсии цифровых форматов (parent)
}
var DigitizationForumIDs = []int{
2219, // Оцифровки с аналоговых носителей (parent)
239, // Отечественная поп-музыка (оцифровки)
1444, // Зарубежная поп-музыка (оцифровки)
450, // Инструментальная поп-музыка (оцифровки)
1756, // Зарубежная рок-музыка (оцифровки)
1758, // Отечественная рок-музыка (оцифровки)
1754, // Электронная музыка (оцифровки)
1660, // Классика и классика в современной обработке (оцифровки)
506, // Фольклор, народная и этническая музыка (оцифровки)
1835, // Rap, Hip-Hop, R'n'B, Reggae, Ska, Dub (оцифровки)
2301, // Джаз и блюз (оцифровки)
1217, // Шансон, авторские, военные песни и марши (оцифровки)
1625, // Саундтреки и мюзиклы (оцифровки)
2401, // Советская эстрада, ретро, романсы (оцифровки)
974, // Музыка других жанров (оцифровки)
}
var LabelPackForumIDs = []int{
782, // Лейбл- и сцен-паки (parent)
1842, // Лейбл-паки (lossless)
1648, // Лейбл-паки, Сцен-паки (lossy)
134, // Неофициальные сборники и ремастеринги (lossless)
965, // Неофициальные сборники (lossy)
577, // AI-Music (lossy и lossless)
2230, // Сборники (lossless)
2231, // Сборники (lossy)
}
var RadioshowForumIDs = []int{
1859, // House (Radioshow, Podcast, Liveset, Mixes)
1824, // Trance (Radioshows, Podcasts, Live Sets, Mixes) (lossy)
1827, // Techno (Radioshows, Podcasts, Livesets, Mixes)
1834, // Drum & Bass, Jungle (Radioshows, Podcasts, Livesets, Mixes)
1838, // Breakbeat, Dubstep (Radioshows, Podcasts, Livesets, Mixes)
460, // Goa Trance, Psy-Trance, PsyChill, Ambient, Dub (Live Sets, Mixes) (lossy)
}
var AACForumIDs = []int{
2240, // Музыка Lossy (AAC-iTunes)
2244, // Музыка Lossy (AAC) (Singles, EPs)
2248, // Музыка Lossy (AAC)
1927, // Музыка lossless (ALAC)
}
var MiscMusicForumIDs = []int{
1395, // Духовные песнопения и музыка (lossless)
1396, // Духовные песнопения и музыка (lossy)
1351, // Сборники песен для детей (lossy и lossless)
2018, // Музыка для бальных танцев (lossy и lossless)
855, // Звуки природы
1929, // Смешанные стили
}
var AllMusicForumIDs = concat(
RockForumIDs,
MetalForumIDs,
AlternativeForumIDs,
PopForumIDs,
ElectronicForumIDs,
HipHopForumIDs,
JazzForumIDs,
BluesForumIDs,
ClassicalForumIDs,
FolkForumIDs,
ReggaeForumIDs,
SoundtrackForumIDs,
ShansonForumIDs,
HiResForumIDs,
DigitizationForumIDs,
LabelPackForumIDs,
RadioshowForumIDs,
AACForumIDs,
MiscMusicForumIDs,
)
func concat(slices ...[]int) []int {
var result []int
for _, s := range slices {
result = append(result, s...)
}
return result
}
-118
View File
@@ -1,118 +0,0 @@
package rutracker
import (
"strconv"
"homelab.lan/music-agregator/internal/tracker"
"homelab.lan/music-agregator/internal/tracker/rutracker/parser"
)
type parserType int
const (
parserGeneral parserType = iota
parserRock
parserMetal
parserAlternative
parserPop
parserElectronic
parserHipHop
parserJazz
parserBlues
parserClassical
parserFolk
parserReggae
parserSoundtracks
parserShanson
parserHiRes
parserDigitization
parserLabelPacks
parserRadioshow
parserAAC
parserMiscMusic
)
var categoryToParser map[int]parserType
func init() {
categoryToParser = make(map[int]parserType)
categoryToParser[3000] = parserGeneral
categoryToParser[3010] = parserGeneral
categoryToParser[3040] = parserGeneral
registerAll(RockForumIDs, parserRock)
registerAll(MetalForumIDs, parserMetal)
registerAll(AlternativeForumIDs, parserAlternative)
registerAll(PopForumIDs, parserPop)
registerAll(ElectronicForumIDs, parserElectronic)
registerAll(HipHopForumIDs, parserHipHop)
registerAll(JazzForumIDs, parserJazz)
registerAll(BluesForumIDs, parserBlues)
registerAll(ClassicalForumIDs, parserClassical)
registerAll(FolkForumIDs, parserFolk)
registerAll(ReggaeForumIDs, parserReggae)
registerAll(SoundtrackForumIDs, parserSoundtracks)
registerAll(ShansonForumIDs, parserShanson)
registerAll(HiResForumIDs, parserHiRes)
registerAll(DigitizationForumIDs, parserDigitization)
registerAll(LabelPackForumIDs, parserLabelPacks)
registerAll(RadioshowForumIDs, parserRadioshow)
registerAll(AACForumIDs, parserAAC)
registerAll(MiscMusicForumIDs, parserMiscMusic)
}
func registerAll(ids []int, pt parserType) {
for _, id := range ids {
categoryToParser[id] = pt
}
}
type ParserFactory struct {
parsers map[parserType]tracker.Parser
}
func NewRuTrackerParserFactory() *ParserFactory {
return &ParserFactory{
parsers: map[parserType]tracker.Parser{
parserGeneral: parser.NewGeneralParser(),
parserRock: parser.NewRockParser(),
parserMetal: parser.NewMetalParser(),
parserAlternative: parser.NewAlternativeParser(),
parserPop: parser.NewPopParser(),
parserElectronic: parser.NewElectronicParser(),
parserHipHop: parser.NewHipHopParser(),
parserJazz: parser.NewJazzParser(),
parserBlues: parser.NewBluesParser(),
parserClassical: parser.NewClassicalParser(),
parserFolk: parser.NewFolkParser(),
parserReggae: parser.NewReggaeParser(),
parserSoundtracks: parser.NewSoundtracksParser(),
parserShanson: parser.NewShansonParser(),
parserHiRes: parser.NewHiResParser(),
parserDigitization: parser.NewVinylDigitizationParser(),
parserLabelPacks: parser.NewLabelPacksParser(),
parserRadioshow: parser.NewRadioshowParser(),
parserAAC: parser.NewAACParser(),
parserMiscMusic: parser.NewMiscMusicParser(),
},
}
}
const jackettIDOffset = 100000
func (f *ParserFactory) GetParser(categories []string) tracker.Parser {
for _, cat := range categories {
catID, err := strconv.Atoi(cat)
if err != nil {
continue
}
if catID >= jackettIDOffset {
catID -= jackettIDOffset
}
if pt, ok := categoryToParser[catID]; ok {
return f.parsers[pt]
}
}
return f.parsers[parserGeneral]
}
-109
View File
@@ -1,109 +0,0 @@
package rutracker
import (
"testing"
"homelab.lan/music-agregator/internal/tracker"
"homelab.lan/music-agregator/internal/tracker/rutracker/parser"
)
func TestParserFactory_GetParser(t *testing.T) {
f := NewRuTrackerParserFactory()
tests := []struct {
name string
categories []string
wantType string
}{
{"torznab general 3000", []string{"3000"}, "*parser.GeneralParser"},
{"torznab general 3010", []string{"3010"}, "*parser.GeneralParser"},
{"torznab general 3040", []string{"3040"}, "*parser.GeneralParser"},
{"rock forum", []string{"1702"}, "*parser.RockParser"},
{"metal forum raw id", []string{"1728"}, "*parser.MetalParser"},
{"metal forum jackett id", []string{"101728"}, "*parser.MetalParser"},
{"alternative forum", []string{"464"}, "*parser.AlternativeParser"},
{"pop forum", []string{"425"}, "*parser.PopParser"},
{"electronic forum", []string{"1857"}, "*parser.ElectronicParser"},
{"hiphop forum", []string{"909"}, "*parser.HipHopParser"},
{"jazz forum", []string{"2277"}, "*parser.JazzParser"},
{"blues forum", []string{"2292"}, "*parser.BluesParser"},
{"classical forum", []string{"556"}, "*parser.ClassicalParser"},
{"folk forum", []string{"1127"}, "*parser.FolkParser"},
{"reggae forum", []string{"1768"}, "*parser.ReggaeParser"},
{"soundtrack forum", []string{"786"}, "*parser.SoundtracksParser"},
{"shanson forum", []string{"1220"}, "*parser.ShansonParser"},
{"hires forum", []string{"1755"}, "*parser.HiResParser"},
{"digitization forum", []string{"239"}, "*parser.VinylDigitizationParser"},
{"label pack forum", []string{"1842"}, "*parser.LabelPacksParser"},
{"radioshow forum", []string{"1859"}, "*parser.RadioshowParser"},
{"aac forum", []string{"2240"}, "*parser.AACParser"},
{"misc music forum", []string{"1395"}, "*parser.MiscMusicParser"},
{"unknown category falls back to general", []string{"99999"}, "*parser.GeneralParser"},
{"empty categories falls back to general", []string{}, "*parser.GeneralParser"},
{"multiple categories uses first match", []string{"99999", "1728"}, "*parser.MetalParser"},
{"jackett prefixed id stripped", []string{"101719"}, "*parser.MetalParser"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := f.GetParser(tt.categories)
gotType := getParserTypeName(p)
if gotType != tt.wantType {
t.Errorf("GetParser(%v) = %v, want %v", tt.categories, gotType, tt.wantType)
}
})
}
}
func getParserTypeName(p tracker.Parser) string {
switch p.(type) {
case *parser.GeneralParser:
return "*parser.GeneralParser"
case *parser.LosslessParser:
return "*parser.LosslessParser"
case *parser.LossyParser:
return "*parser.LossyParser"
case *parser.HiResParser:
return "*parser.HiResParser"
case *parser.VinylDigitizationParser:
return "*parser.VinylDigitizationParser"
case *parser.ClassicalParser:
return "*parser.ClassicalParser"
case *parser.JazzParser:
return "*parser.JazzParser"
case *parser.MetalParser:
return "*parser.MetalParser"
case *parser.SoundtracksParser:
return "*parser.SoundtracksParser"
case *parser.DiscographyParser:
return "*parser.DiscographyParser"
case *parser.LabelPacksParser:
return "*parser.LabelPacksParser"
case *parser.RockParser:
return "*parser.RockParser"
case *parser.AlternativeParser:
return "*parser.AlternativeParser"
case *parser.PopParser:
return "*parser.PopParser"
case *parser.ElectronicParser:
return "*parser.ElectronicParser"
case *parser.HipHopParser:
return "*parser.HipHopParser"
case *parser.BluesParser:
return "*parser.BluesParser"
case *parser.FolkParser:
return "*parser.FolkParser"
case *parser.ReggaeParser:
return "*parser.ReggaeParser"
case *parser.ShansonParser:
return "*parser.ShansonParser"
case *parser.RadioshowParser:
return "*parser.RadioshowParser"
case *parser.AACParser:
return "*parser.AACParser"
case *parser.MiscMusicParser:
return "*parser.MiscMusicParser"
default:
return "unknown"
}
}
-25
View File
@@ -1,25 +0,0 @@
package rutracker
import "strconv"
type Filter struct{}
func NewFilter() *Filter {
return &Filter{}
}
func (f *Filter) IsKnownCategory(categories []string) bool {
for _, cat := range categories {
catID, err := strconv.Atoi(cat)
if err != nil {
continue
}
if catID >= jackettIDOffset {
catID -= jackettIDOffset
}
if _, ok := categoryToParser[catID]; ok {
return true
}
}
return false
}
-35
View File
@@ -1,35 +0,0 @@
package rutracker
import "testing"
func TestFilter_IsKnownCategory(t *testing.T) {
f := NewFilter()
tests := []struct {
name string
categories []string
want bool
}{
{"torznab lossless", []string{"3040"}, true},
{"torznab lossy", []string{"3010"}, true},
{"torznab general audio", []string{"3000"}, true},
{"rutracker pop forum", []string{"425"}, true},
{"rutracker hires forum", []string{"1755"}, true},
{"rutracker metal forum", []string{"1728"}, true},
{"jackett prefixed id", []string{"101728"}, true},
{"unknown category", []string{"99999"}, false},
{"empty categories", []string{}, false},
{"books category", []string{"7000"}, false},
{"mixed known and unknown", []string{"99999", "3040"}, true},
{"invalid non-numeric", []string{"abc"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := f.IsKnownCategory(tt.categories)
if got != tt.want {
t.Errorf("IsKnownCategory(%v) = %v, want %v", tt.categories, got, tt.want)
}
})
}
}
-37
View File
@@ -1,37 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type AACParser struct {
BaseParser
}
func NewAACParser() *AACParser {
return &AACParser{}
}
func (p *AACParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,142 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestAACParser(t *testing.T) {
p := NewAACParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantBitrate string
wantParseOK bool
}{
{
name: "Pop AAC VBR",
title: "(Pop) Zivert - Айсберг (Apple Music Home Session) - 2022, AAC (tracks), VBR 256 kbps",
wantArtist: "Zivert",
wantYear: 2022,
wantFormat: release.FormatAAC,
wantParseOK: true,
},
{
name: "OST ALAC CD",
title: "(OST) [CD] Rockstar Games Presents Music From And Inspired By Grand Theft Auto IV: Vladivostok FM - 2008, ALAC (tracks+.cue), lossless",
wantArtist: "Rockstar Games Presents Music From And Inspired By Grand Theft Auto IV: Vladivostok FM",
wantYear: 2008,
wantFormat: release.FormatALAC,
wantType: release.TypeSoundtrack,
wantParseOK: true,
},
{
name: "Hip-hop ALAC discography",
title: "(Hip-Hop, rap rock, hardcore rap, chopper) [CD`39|WEB`6] [Strange Music] Tech N9ne - Дискография / Discography - 1999-2025, ALAC (tracks+.cue), lossless",
wantArtist: "Tech N9ne",
wantYear: 1999,
wantFormat: release.FormatALAC,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "Art rock iTunes AAC",
title: "(Art Rock / Pop Rock) Roxy Music - Дискография / iTunes Discography - 1972-2004 [WEB], AAC (tracks), 256 kbps",
wantArtist: "Roxy Music",
wantYear: 1972,
wantFormat: release.FormatAAC,
wantType: release.TypeDiscography,
wantBitrate: "256 kbps",
wantParseOK: true,
},
{
name: "Jazz AAC 320",
title: "(Jazz, Post-Bop, Modal Music) Masaru Imada + Kenji Kohsei Quartet - All Of A Glow (Hiroshi Murakami, Kenji Kosei, Masaru Imada, Nobuyoshi Ino) - 1978, AAC (tracks), 320 kbps",
wantArtist: "Masaru Imada + Kenji Kohsei Quartet",
wantYear: 1978,
wantFormat: release.FormatAAC,
wantBitrate: "320 kbps",
wantParseOK: true,
},
{
name: "Rock pop ALAC digital master",
title: "(Rock Pop) [WEB] Bryan Adams - Ultimate [Apple Music Digital Master] {24-44.1} - 2017, ALAC (tracks), lossless",
wantArtist: "Bryan Adams",
wantYear: 2017,
wantFormat: release.FormatALAC,
wantParseOK: true,
},
{
name: "Alternative electronic VA AAC",
title: "(Alternative, Electronic) VA - Astralwerks - Music In 20/20 (Feat. The Chemical Brothers, Doves, Swedish House Mafia, Air, Diamond Rings, Hot Chip, Kings Of Convenience, The Kooks, Kraftwerk & more) - 2013, AAC (tracks), TVBR q127",
wantArtist: "VA",
wantYear: 2013,
wantFormat: release.FormatAAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Eurodance ALAC multi-CD",
title: "(EuroHouse, EuroDance, Other) [CD] VA - Promotion Dance Hits (Snake's Music) (22 CD), 1994-1996, ALAC, (tracks+.cue), lossless [не flac]",
wantArtist: "VA",
wantYear: 1994,
wantFormat: release.FormatALAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Lounge chill jazz christmas AAC",
title: "(Lounge, Chill Out, Jazz) VA - Christmas Jazz Night 1-7 (Best X-Mas Jazz Music) - 2017-2023, AAC (tracks), TVBR q127 (WEB)",
wantArtist: "VA",
wantYear: 2017,
wantFormat: release.FormatAAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Electro house dance AAC",
title: "(Electro, House, Dance) VA - Music & Fashion (The Deep-House Shows), Vol. 1-4 - 2023, AAC (tracks), TVBR q127 (WEB)",
wantArtist: "VA",
wantYear: 2023,
wantFormat: release.FormatAAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
})
}
}
@@ -1,38 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type AlternativeParser struct {
BaseParser
}
func NewAlternativeParser() *AlternativeParser {
return &AlternativeParser{}
}
func (p *AlternativeParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Alternative"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,133 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestAlternativeParser(t *testing.T) {
p := NewAlternativeParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantBitrate string
wantParseOK bool
}{
{
name: "Nu-metal album",
title: "(Nu-Metal, Alternative Metal) [WEB] Korn - Reward the Scars - 2026, FLAC (tracks), lossless",
wantArtist: "Korn",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Punk rock discography",
title: "(Punk Rock / Alternative Rock) [CD / WEB] Bayside - Дискография - 2001-2025, (21 CD), FLAC (tracks+cue, tracks), lossless",
wantArtist: "Bayside",
wantYear: 2001,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Alternative metal discography",
title: "(Alternative Metal / Nu Metal) [CD / WEB] Sevendust - Дискография - 1997-2026, (35 CD), FLAC (tracks+cue, tracks), lossless",
wantArtist: "Sevendust",
wantYear: 1997,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Alt rock female vocals",
title: "(Alt. Rock / Alternative Metal / Female Vocals) [WEB] EarlyRise - The Flood Is Coming - 2026, FLAC (tracks), lossless",
wantArtist: "EarlyRise",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Alternative rock electronic",
title: "(Alternative Rock / Post-Hardcore / Electronic) [WEB] Nvtures Ghost - I Have No Moth And I Must Scream - 2026, FLAC (tracks), lossless",
wantArtist: "Nvtures Ghost",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Alternative discography",
title: "(Alternative) [WEB] KSB muzic - Дискография - 2022-2025, FLAC (tracks), lossless",
wantArtist: "KSB muzic",
wantYear: 2022,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Russian indie discography",
title: "(Russian Indie, Indie, Rock, Punk, Alternative,) [WEB] Полматери -Дискография (15 релизов) - 2019-2026, FLAC (tracks), lossless",
wantArtist: "Полматери",
wantYear: 2019,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Britpop discography",
title: "(Britpop / Alternative Rock / Indie Rock) [CD / WEB] elbow - Дискография - 2001-2025, (51 CD), FLAC (tracks+cue, tracks), lossless",
wantArtist: "elbow",
wantYear: 2001,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Alternative rock CD",
title: "(Alternative Rock) [CD] Foo Fighters - Your Favorite Toy - 2026, FLAC (tracks+.cue), lossless",
wantArtist: "Foo Fighters",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Smashing Pumpkins multi-genre",
title: "(Alternative Rock, Shoegaze, Noise Rock, Dream Pop, Alternative Metal) [CD] The Smashing Pumpkins - Machina II: The Friends & Enemies Of Modern Music (Q101) - 2000 (2 CD), FLAC (tracks+.cue), lossless",
wantArtist: "The Smashing Pumpkins",
wantYear: 2000,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
})
}
}
-282
View File
@@ -1,282 +0,0 @@
package parser
import (
"strconv"
"strings"
"homelab.lan/music-agregator/internal/release"
)
type BaseParser struct{}
func (p *BaseParser) NewRelease(title string) *release.Release {
return &release.Release{
RawTitle: title,
ParsedSuccessfully: true,
}
}
func (p *BaseParser) ExtractGenres(title string) []string {
match := genrePattern.FindStringSubmatch(title)
if len(match) < 2 {
return nil
}
raw := match[1]
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '/' || r == ';'
})
var genres []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
genres = append(genres, trimmed)
}
}
return genres
}
func (p *BaseParser) StripGenrePrefix(title string) string {
return genrePattern.ReplaceAllString(title, "")
}
func (p *BaseParser) StripLeadingTags(title string) string {
return leadingTagsPattern.ReplaceAllString(title, "")
}
func (p *BaseParser) ExtractYear(title string) int {
if match := reissueYearPattern.FindStringSubmatch(title); len(match) >= 2 {
year, _ := strconv.Atoi(match[1])
return year
}
if match := releaseYearPattern.FindStringSubmatch(title); len(match) >= 2 {
year, _ := strconv.Atoi(match[1])
return year
}
match := yearPattern.FindStringSubmatch(title)
if len(match) < 2 {
return 0
}
year, _ := strconv.Atoi(match[1])
return year
}
func (p *BaseParser) ExtractYearRange(title string) (int, int) {
if match := releaseYearPattern.FindStringSubmatch(title); len(match) >= 2 {
year, _ := strconv.Atoi(match[1])
return year, 0
}
if match := reissueYearPattern.FindStringSubmatch(title); len(match) >= 2 {
year, _ := strconv.Atoi(match[1])
return year, 0
}
rangeMatch := yearRangePattern.FindStringSubmatch(title)
if len(rangeMatch) >= 3 {
start, _ := strconv.Atoi(rangeMatch[1])
end, _ := strconv.Atoi(rangeMatch[2])
return start, end
}
match := yearPattern.FindStringSubmatch(title)
if len(match) >= 2 {
year, _ := strconv.Atoi(match[1])
return year, 0
}
return 0, 0
}
func (p *BaseParser) ExtractFormat(title string) release.AudioFormat {
match := formatPattern.FindStringSubmatch(title)
if len(match) < 2 {
return release.FormatUnknown
}
format := strings.ToUpper(match[1])
switch {
case format == "FLAC":
return release.FormatFLAC
case format == "MP3":
return release.FormatMP3
case format == "AAC":
return release.FormatAAC
case format == "APE":
return release.FormatAPE
case format == "WV" || format == "WAVPACK":
return release.FormatWavPack
case format == "ALAC":
return release.FormatALAC
case format == "OGG":
return release.FormatOGG
case format == "WAV":
return release.FormatWAV
default:
return release.FormatUnknown
}
}
func (p *BaseParser) ExtractBitrate(title string) string {
if strings.Contains(strings.ToLower(title), "lossless") {
return "lossless"
}
match := bitratePattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
if match[1] != "" {
return match[1] + " kbps"
}
if match[2] != "" {
return "V" + match[2]
}
if match[3] != "" {
return "VBR ~" + match[3] + " kbps"
}
if match[4] != "" && match[5] != "" {
return "VBR " + match[4] + "-" + match[5] + " kbps"
}
return ""
}
func (p *BaseParser) ExtractRipType(title string) string {
match := ripTypePattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
return strings.ToLower(match[1])
}
func (p *BaseParser) ExtractSource(title string) release.Source {
match := sourceTagPattern.FindStringSubmatch(title)
if len(match) < 2 {
if strings.Contains(strings.ToLower(title), "web") {
return release.SourceWEB
}
return release.SourceUnknown
}
tag := strings.ToUpper(match[1])
switch tag {
case "CD":
return release.SourceCD
case "WEB":
return release.SourceWEB
case "LP", "VINYL", "MINI-LP", "EP", "12\"", "10\"", "7\"":
return release.SourceVinyl
case "SACD", "DVDA", "HDAD":
return release.SourceDVD
default:
return release.SourceUnknown
}
}
func (p *BaseParser) ExtractHiRes(title string) (bitDepth int, sampleRate int) {
match := hiResPattern.FindStringSubmatch(title)
if len(match) < 3 {
return 0, 0
}
bitDepth, _ = strconv.Atoi(match[1])
sr := match[2]
if strings.Contains(sr, ".") {
f, _ := strconv.ParseFloat(sr, 64)
sampleRate = int(f * 1000)
} else {
sampleRate, _ = strconv.Atoi(sr)
sampleRate *= 1000
}
return bitDepth, sampleRate
}
func (p *BaseParser) ExtractSpecialTags(title string) []string {
matches := specialTagPattern.FindAllStringSubmatch(title, -1)
var tags []string
for _, match := range matches {
if len(match) >= 2 {
tags = append(tags, match[1])
}
}
return tags
}
func (p *BaseParser) ExtractReleaseCount(title string) int {
match := releaseCountPattern.FindStringSubmatch(title)
if len(match) < 2 {
return 0
}
count, _ := strconv.Atoi(match[1])
return count
}
func (p *BaseParser) ExtractLabel(title string) string {
match := labelPattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
return strings.TrimSpace(match[1])
}
func (p *BaseParser) ExtractCatalogNum(title string) string {
match := catalogNumPattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
return match[1]
}
func (p *BaseParser) DetectType(title string) release.Type {
switch {
case discographyPattern.MatchString(title):
return release.TypeDiscography
case collectionPattern.MatchString(title):
return release.TypeCollection
case bootlegPattern.MatchString(title):
return release.TypeBootleg
case anthologyPattern.MatchString(title):
return release.TypeCollection
case soundtrackPattern.MatchString(title):
return release.TypeSoundtrack
case livePattern.MatchString(title):
return release.TypeLive
case epPattern.MatchString(title):
return release.TypeEP
case singlePattern.MatchString(title):
return release.TypeSingle
case bestOfPattern.MatchString(title):
return release.TypeCompilation
case compilationPattern.MatchString(title):
return release.TypeCompilation
default:
return release.TypeAlbum
}
}
func (p *BaseParser) ExtractArtistAlbum(title string) (artist string, album string) {
cleaned := tagsBeforeGenrePattern.ReplaceAllString(title, "")
cleaned = p.StripGenrePrefix(cleaned)
cleaned = p.StripLeadingTags(cleaned)
cleaned = trailingTechPattern.ReplaceAllString(cleaned, "")
if match := standardTitlePattern.FindStringSubmatch(cleaned); len(match) >= 3 {
return strings.TrimSpace(match[1]), strings.TrimSpace(match[2])
}
if match := altTitlePattern.FindStringSubmatch(cleaned); len(match) >= 3 {
return strings.TrimSpace(match[1]), strings.TrimSpace(match[2])
}
parts := strings.SplitN(cleaned, " - ", 3)
if len(parts) >= 2 {
artist = strings.TrimSpace(parts[0])
albumPart := strings.TrimSpace(parts[1])
albumPart = yearPattern.ReplaceAllString(albumPart, "")
albumPart = strings.Trim(albumPart, " -,")
album = albumPart
}
return artist, album
}
func (p *BaseParser) AddError(r *release.Release, err string) {
r.ParseErrors = append(r.ParseErrors, err)
r.ParsedSuccessfully = false
}
@@ -1,38 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type BluesParser struct {
BaseParser
}
func NewBluesParser() *BluesParser {
return &BluesParser{}
}
func (p *BluesParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Blues"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,133 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestBluesParser(t *testing.T) {
p := NewBluesParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantBitrate string
wantParseOK bool
}{
{
name: "Blues rock classic rock reissue",
title: "(Blues Rock, Classic Rock) [CD] Rory Gallagher - Against the Grain - 2018 (1975), FLAC (image+.cue), lossless",
wantArtist: "Rory Gallagher",
wantYear: 2018,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues album WEB",
title: "(Blues) [WEB] Roger C. Wade & The Houserockers - Shake it loose! - 2026, FLAC (tracks), lossless",
wantArtist: "Roger C. Wade & The Houserockers",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues rock soldier",
title: "(Blues Rock) [WEB] Krissy Matthews - Rock and Roll Soldier - 2026, FLAC (tracks), lossless",
wantArtist: "Krissy Matthews",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues folk album",
title: "(Blues, Folk) [WEB] Gurf Morlix - Cobwebs & Stardust - 2026, FLAC (tracks), lossless",
wantArtist: "Gurf Morlix",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues dan penn",
title: "(Blues) [WEB] Dan Penn - Smoke Filled Room - 2026, FLAC (tracks), lossless",
wantArtist: "Dan Penn",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues rock paradise",
title: "(Blues Rock) [WEB] Catfish John Tisdell - Blues in Paradise - 2026, FLAC (tracks), lossless",
wantArtist: "Catfish John Tisdell",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues shades",
title: "(Blues) [WEB] Carrie Marshall - Shades of Blue - 2026, FLAC (tracks), lossless",
wantArtist: "Carrie Marshall",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues rock dont be mean",
title: "(Blues Rock) [WEB] Boogie Beasts - Don't Be So Mean! - 2026, FLAC (tracks), lossless",
wantArtist: "Boogie Beasts",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues against machine",
title: "(Blues) [WEB] Blues Against The Machine - VOL. II - 2026, FLAC (tracks), lossless",
wantArtist: "Blues Against The Machine",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Blues bon appetit",
title: "(Blues) [WEB] Andhrea and the Black Cats - Bon Appetit!! - 2026, FLAC (tracks), lossless",
wantArtist: "Andhrea and the Black Cats",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
})
}
}
@@ -1,38 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type ClassicalParser struct {
BaseParser
}
func NewClassicalParser() *ClassicalParser {
return &ClassicalParser{}
}
func (p *ClassicalParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Classical"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,130 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestClassicalParser(t *testing.T) {
p := NewClassicalParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantGenres []string
wantParseOK bool
}{
{
name: "Rachmaninoff concerto",
title: "(Classical) [CD] Rachmaninoff - Piano Concerto No.3 - Nobuyuki Tsujii, Royal Liverpool Philharmonic Orchestra - 2026, FLAC (image+.cue) lossless",
wantArtist: "Rachmaninoff",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantType: release.TypeAlbum,
wantGenres: []string{"Classical"},
wantParseOK: true,
},
{
name: "Shostakovich symphonies collection",
title: "(Classical) [CD] Dmitry Shostakovich - Symphonies 1-15 (Boston Symphony Orchestra, Andris Nelsons) [19 CDs] - 2025, FLAC (image+.cue) lossless",
wantArtist: "Dmitry Shostakovich",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "TR24 OF Brahms symphonies",
title: "[TR24][OF] Brahms - The Complete Symphonies (Royal Concertgebouw Orchestra, John Eliot Gardiner) - 2025 (Classical)",
wantArtist: "Brahms",
wantYear: 2025,
wantParseOK: true,
},
{
name: "Haitink complete recordings",
title: "(Classical) [CD] Bernard Haitink - Concertgebouworkest Edition Complete Studio Recordings [113 CDs] - 2022, FLAC (image+.cue) lossless",
wantArtist: "Bernard Haitink",
wantYear: 2022,
wantType: release.TypeCollection,
wantParseOK: true,
},
{
name: "Tchaikovsky symphonies",
title: "(Classical) [CD] Чайковский - Complete 8 Symphonies plus Concertos [10 CDs] - 2024, FLAC (image+.cue) lossless",
wantArtist: "Чайковский",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Wagner opera TR24",
title: "[TR24][OF] Wagner - Siegfried (Symphonieorchester des Bayerischen Rundfunks, Sir Simon Rattle) - 2025 (Opera)",
wantArtist: "Wagner",
wantYear: 2025,
wantParseOK: true,
},
{
name: "Strauss Elektra opera",
title: "[TR24][OF] Irène Theorin, Bergen Philharmonic Orchestra - R. Strauss Elektra Op. 58 - 2026 (Classical, Opera)",
wantYear: 2026,
wantParseOK: true,
},
{
name: "Bruckner symphonies remaster",
title: "[TR24][OF] Bruckner - Symphonies Nos. 5 and 6 (New Philharmonia Orchestra, Otto Klemperer) - 2024 (Classical)",
wantArtist: "Bruckner",
wantYear: 2024,
wantParseOK: true,
},
{
name: "DSD Brahms chamber music",
title: "[DSD][OF] The Brahms Project - Brahms The Complete Piano Quartets - 2017 (Classical, Chamber Music)",
wantArtist: "The Brahms Project",
wantYear: 2017,
wantParseOK: true,
},
{
name: "DSD Mozart symphonies",
title: "[DSD][OF] Concertgebouw Chamber Orchestra - Mozart Symphonies - 2015 (Classical)",
wantArtist: "Concertgebouw Chamber Orchestra",
wantYear: 2015,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if len(tt.wantGenres) > 0 && len(r.Genres) == 0 {
if r.Genres[0] != "Classical" {
t.Errorf("Genres[0] = %q, want Classical", r.Genres[0])
}
}
})
}
}
@@ -1,54 +0,0 @@
package parser
import (
"strings"
"homelab.lan/music-agregator/internal/release"
)
type DiscographyParser struct {
BaseParser
}
func NewDiscographyParser() *DiscographyParser {
return &DiscographyParser{}
}
func (p *DiscographyParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
if collectionPattern.MatchString(title) {
r.Type = release.TypeCollection
} else {
r.Type = release.TypeDiscography
}
r.Artist = p.extractDiscographyArtist(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
func (p *DiscographyParser) extractDiscographyArtist(title string) string {
if match := discographyTitlePattern.FindStringSubmatch(title); len(match) >= 2 {
return strings.TrimSpace(match[1])
}
if match := collectionTitlePattern.FindStringSubmatch(title); len(match) >= 2 {
return strings.TrimSpace(match[1])
}
artist, _ := p.ExtractArtistAlbum(title)
return artist
}
@@ -1,152 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestDiscographyParser(t *testing.T) {
p := NewDiscographyParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantYearEnd int
wantReleaseCount int
wantType release.Type
wantFormat release.AudioFormat
wantParseOK bool
}{
{
name: "Russian discography with ALAC",
title: "(Metalcore, progressive metalcore, alternative metal, mathcore) [CD`12] Architects - Дискография / Discography - 2006-2025, ALAC (tracks+.cue), lossless",
wantArtist: "Architects",
wantYear: 2006,
wantYearEnd: 2025,
wantType: release.TypeDiscography,
wantFormat: release.FormatALAC,
wantParseOK: true,
},
{
name: "discography with CD count",
title: "(Rock / Hard Rock / Power-Pop) [CD] Cheap Trick - Дискография - 1977-2021 (78 CD), FLAC (image+.cue), lossless",
wantArtist: "Cheap Trick",
wantYear: 1977,
wantYearEnd: 2021,
wantReleaseCount: 78,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "mixed CD and WEB",
title: "Pompeya - Дискография | Discography (3 CD, 6 WEB) - 2011-2015, FLAC (tracks+.cue, tracks/web), lossless",
wantArtist: "Pompeya",
wantYear: 2011,
wantYearEnd: 2015,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "large discography with releases count",
title: "(Rock) Александр Башлачёв - Дискография (1994-2025) (35 выпусков, 47 CD / 2 Digital Release), FLAC (image+.cue), lossless",
wantArtist: "Александр Башлачёв",
wantYear: 1994,
wantYearEnd: 2025,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "very large discography",
title: "(Rock) Аквариум и Борис Гребенщиков (БГ) - Дискография - 1973–2023 (222 издания, 245 CD), FLAC (image+.cue), lossless",
wantArtist: "Аквариум и Борис Гребенщиков (БГ)",
wantYear: 1973,
wantYearEnd: 2023,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "metal discography",
title: "(Heavy Metal) [CD] Saxon - Дискография (58 CD) - 1979-2024, FLAC (image+.cue), lossless",
wantArtist: "Saxon",
wantYear: 1979,
wantYearEnd: 2024,
wantReleaseCount: 58,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "detailed Queen discography",
title: "(Progressive Hard Rock Fusion) [CD] Queen The Discography / Дискография (15 Studio, 11 Live, 13 Compilation, 63 Singles, 2 Collaboration, 7 Box Set, 243 issues, 336 CD) - 1973-2015, FLAC (image+.cue), lossless",
wantArtist: "Queen",
wantYear: 1973,
wantYearEnd: 2015,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "English discography",
title: "(Rock, Pop) [CD] U2 - Discography (1980-2017), FLAC (tracks+.cue), lossless",
wantArtist: "U2",
wantYear: 1980,
wantYearEnd: 2017,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "death metal discography",
title: "(Technical Brutal Death Metal) [CD] Nile - Discography (1994 - 2024) 13 CD, FLAC (image+.cue), lossless",
wantArtist: "Nile",
wantYear: 1994,
wantYearEnd: 2024,
wantReleaseCount: 13,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "collection keyword",
title: "(Pop) Madonna - Коллекция / Collection - 65 релизов (2 Albums, 22 Singles, 13 Megamixes, 8 Live, 17 Collections, 3 Bonus) (1982-2012), MP3, 128-320, VBR kbps",
wantArtist: "Madonna",
wantYear: 1982,
wantYearEnd: 2012,
wantType: release.TypeCollection,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantYearEnd != 0 && r.YearEnd != tt.wantYearEnd {
t.Errorf("YearEnd = %d, want %d", r.YearEnd, tt.wantYearEnd)
}
if tt.wantReleaseCount != 0 && r.ReleaseCount != tt.wantReleaseCount {
t.Errorf("ReleaseCount = %d, want %d", r.ReleaseCount, tt.wantReleaseCount)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
})
}
}
@@ -1,38 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type ElectronicParser struct {
BaseParser
}
func NewElectronicParser() *ElectronicParser {
return &ElectronicParser{}
}
func (p *ElectronicParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Electronic"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,142 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestElectronicParser(t *testing.T) {
p := NewElectronicParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantBitrate string
wantParseOK bool
}{
{
name: "Progressive house VA",
title: "(Progressive House) [WEB] VA - Augmented 018 / FGA (Mango Alley [ALLEYAUG018]) - 2026, FLAC (tracks), lossless",
wantArtist: "VA",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Electro synth-pop tech house",
title: "(Electro, Synth-Pop, Tech House) [CD] VA - Kitsune Maison Compilation 6 - 2008, FLAC (tracks+.cue), lossless",
wantArtist: "VA",
wantYear: 2008,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Deep house multi-CD",
title: "(Deep House, House, Tech House, Minimal Techno) [2 CD] VA - Freza & Nitrous - Air Trip - 2012, FLAC (tracks+.cue), lossless",
wantArtist: "VA",
wantYear: 2012,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "House fresh majestic",
title: "(House) [2 CD] VA - Fresh & Majestic - defile spb [2005] - 2005, FLAC (image+.cue), lossless",
wantArtist: "VA",
wantYear: 2005,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Trance breaks house",
title: "(Trance, Breaks, House) [2 CD] VA - Fantazia - Aural Pleasure - 2000, FLAC (tracks+.cue), lossless",
wantArtist: "VA",
wantYear: 2000,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "House klubnyi",
title: "(House) [CD] VA - E Burg KLUBНЫЙ by Smart #5 - 2006, FLAC (image+.cue), lossless",
wantArtist: "VA",
wantYear: 2006,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "House progressive artist release",
title: "(House, Progressive house) [WEB] Thomas Newson - Summer Vibes (Armada Music[ARMAS1092A]) - 2015, FLAC (tracks), lossless",
wantArtist: "Thomas Newson",
wantYear: 2015,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Progressive trance dream",
title: "(Progressive Trance, Euro House, Trance, Dream) [CD] VA - Dream Power 7 - 1997, FLAC (image+.cue), lossless",
wantArtist: "VA",
wantYear: 1997,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Progressive house hard house",
title: "(Progressive House, Hard House) [CD] VA - Future Russian House - 2001, FLAC (tracks+.cue), lossless",
wantArtist: "VA",
wantYear: 2001,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Progressive house trance 2002",
title: "(Progressive House, Trance) [CD] VA - Future Russian House 2002 - 2002, FLAC (tracks+.cue), lossless",
wantArtist: "VA",
wantYear: 2002,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
})
}
}
-38
View File
@@ -1,38 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type FolkParser struct {
BaseParser
}
func NewFolkParser() *FolkParser {
return &FolkParser{}
}
func (p *FolkParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Folk"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,138 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestFolkParser(t *testing.T) {
p := NewFolkParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantBitrate string
wantParseOK bool
}{
{
name: "Folk italo VA compilation",
title: "(Folk, Italo Folk, Italian Folk) [WEB] VA - La musica della mafia - Best of (Uomini d'onore - Men of Honor) - 2011, FLAC (tracks), lossless",
wantArtist: "VA",
wantYear: 2011,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Russian folk VA",
title: "[RUS](Folk) [CD] VA - Пинежская песня. Том I-III, V, VI - 2011-2016, FLAC (image+.cue), lossless",
wantArtist: "VA",
wantYear: 2011,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Folk world country best of",
title: "(Folk, World, & Country) [CD] Suzy Bogguss - Greatest Hits - 1994, FLAC (tracks+.cue), lossless",
wantArtist: "Suzy Bogguss",
wantYear: 1994,
wantFormat: release.FormatFLAC,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Folk christmas multi-CD",
title: "(Folk) [CD] The Allisons - Sing Christmas (2 CD) - 1995, FLAC (image+.cue), lossless",
wantArtist: "The Allisons",
wantYear: 1995,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Folk world country album",
title: "(Folk, World, & Country) [CD] Maura O'Connell - Helpless Heart - 1989, FLAC (tracks+.cue), lossless",
wantArtist: "Maura O'Connell",
wantYear: 1989,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Country blues folk collection",
title: "(Country, Blues, Folk, Americana, Guitar, Vocal) [WEB] Jack Barksdale - Collection of 2 Albums, 2 EP and 8 singles / Коллекция из 12 релизов - 2017-2023, FLAC (tracks), lossless",
wantArtist: "Jack Barksdale",
wantYear: 2017,
wantFormat: release.FormatFLAC,
wantType: release.TypeCollection,
wantParseOK: true,
},
{
name: "Russian dark folk neofolk collection",
title: "[RUS] (Folk, Dark Folk, Neofolk) [CD] Помни Имя Своё - Коллекция (4 релиза, 6 CD) - 2016-2023, FLAC (image+.cue), lossless",
wantArtist: "Помни Имя Своё",
wantYear: 2016,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Folk country rock collection",
title: "(Folk, Country rock) [CD] Emmylou Harris - коллекция 1975-2008 (23 альбома), FLAC (image+.cue, tracks+.cue), lossless",
wantArtist: "Emmylou Harris",
wantYear: 1975,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Ukrainian folk electronic collection",
title: "[UKR] (Folk, Electronic, Dance, Pop) [WEB] Go A - Collection - 2016-2026, FLAC (tracks), 17 CD (1 Album, 16 Singles), lossless",
wantArtist: "Go A",
wantYear: 2016,
wantFormat: release.FormatFLAC,
wantType: release.TypeCollection,
wantParseOK: true,
},
{
name: "Alternative country folk rock",
title: "(Alternative Country, Folk Rock) [CD] Kathleen Edwards - Asking for Flowers - 2008, FLAC (tracks+.cue), lossless",
wantArtist: "Kathleen Edwards",
wantYear: 2008,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
})
}
}
@@ -1,35 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type GeneralParser struct {
BaseParser
}
func NewGeneralParser() *GeneralParser {
return &GeneralParser{}
}
func (p *GeneralParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,166 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestGeneralParser(t *testing.T) {
p := NewGeneralParser()
tests := []struct {
name string
title string
wantArtist string
wantAlbum string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantGenres []string
wantSource release.Source
wantRipType string
wantBitrate string
wantParseOK bool
}{
{
name: "standard CD rip with genre",
title: "(Rock) [CD] Thin Lizzy - Acoustic Sessions - 2024 (Decca Records EU 2025), FLAC (image+.cue), lossless",
wantArtist: "Thin Lizzy",
wantAlbum: "Acoustic Sessions",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantType: release.TypeAlbum,
wantGenres: []string{"Rock"},
wantSource: release.SourceCD,
wantRipType: "image+.cue",
wantBitrate: "lossless",
wantParseOK: true,
},
{
name: "multi-genre CD rip",
title: "(Hard Rock, Glam Rock, Progressive Rock, Art Rock, Heavy Metal) [CD] Queen Queen I (2 CD) 2024 , FLAC (image+.cue), lossless",
wantArtist: "Queen",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantType: release.TypeAlbum,
wantGenres: []string{"Hard Rock", "Glam Rock", "Progressive Rock", "Art Rock", "Heavy Metal"},
wantSource: release.SourceCD,
wantParseOK: true,
},
{
name: "WEB release with tracks",
title: "(Progressive Rock) [WEB] Opeth - In Cauda Venenum (Extended Edition) - 2019/2022, FLAC (tracks), lossless",
wantArtist: "Opeth",
wantYear: 2019,
wantFormat: release.FormatFLAC,
wantSource: release.SourceWEB,
wantRipType: "tracks",
wantParseOK: true,
},
{
name: "Japan release",
title: "(Pop-Rock Soft-Rock) [CD] Sting - The Soul Cages (Expanded Edition) - 2025 [Japan], FLAC (image+.cue), lossless",
wantArtist: "Sting",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantSource: release.SourceCD,
wantParseOK: true,
},
{
name: "live album",
title: "(Rock) [CD] Bryan Adams - Live at the Royal Albert Hall - 2024, FLAC (image+.cue), lossless",
wantArtist: "Bryan Adams",
wantType: release.TypeLive,
wantYear: 2024,
wantParseOK: true,
},
{
name: "soundtrack",
title: "(Pop) [CD] Celine Dion - I AM - Celine Dion (Original Motion Picture Soundtrack) - 2024 [Japan], FLAC (image+.cue), lossless",
wantArtist: "Celine Dion",
wantType: release.TypeSoundtrack,
wantYear: 2024,
wantParseOK: true,
},
{
name: "deluxe box set",
title: "(Rock) [CD] Bryan Adams - Roll With The Punches (Deluxe Box Set) - 2025, FLAC (image+.cue), lossless",
wantArtist: "Bryan Adams",
wantYear: 2025,
wantParseOK: true,
},
{
name: "CDS single",
title: "(Heavy Metal) [CDS] Bruce Dickinson - Resurrection Men - 2024, FLAC (image+.cue), lossless",
wantArtist: "Bruce Dickinson",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "tracks+cue format",
title: "(Classic Rock) [CD] The Who - Who Are You (Super Deluxe Edition) - 2025, FLAC (tracks+cue), lossless",
wantArtist: "The Who",
wantYear: 2025,
wantRipType: "tracks+cue",
wantParseOK: true,
},
{
name: "WEB with special artist name",
title: "(Chamber Pop) [WEB] Florence + the Machine - Ceremonials (Digital Deluxe Edition) - 2011, FLAC (tracks), lossless",
wantArtist: "Florence + the Machine",
wantYear: 2011,
wantSource: release.SourceWEB,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantAlbum != "" && r.Album != tt.wantAlbum {
t.Errorf("Album = %q, want %q", r.Album, tt.wantAlbum)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
if len(tt.wantGenres) > 0 {
if len(r.Genres) != len(tt.wantGenres) {
t.Errorf("Genres count = %d, want %d", len(r.Genres), len(tt.wantGenres))
}
}
})
}
}
@@ -1,38 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type HipHopParser struct {
BaseParser
}
func NewHipHopParser() *HipHopParser {
return &HipHopParser{}
}
func (p *HipHopParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Hip-Hop"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,138 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestHipHopParser(t *testing.T) {
p := NewHipHopParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantBitrate string
wantParseOK bool
}{
{
name: "Rap underground discography",
title: "(Rap | Underground Hip-Hop) Небро | Честер / Chester (Небро) - Дискография, (при уч. НЕ.KURILI) , (39 Релизов), - 2008-2026, MP3, 256-320 kbps",
wantArtist: "Небро | Честер / Chester (Небро)",
wantYear: 2008,
wantFormat: release.FormatMP3,
wantParseOK: true,
},
{
name: "Rap album MP3",
title: "(Rap) Честер Небро & НЕ.KURILI - Короткометражка - 2026, MP3, 320 kbps",
wantArtist: "Честер Небро & НЕ.KURILI",
wantYear: 2026,
wantFormat: release.FormatMP3,
wantBitrate: "320 kbps",
wantParseOK: true,
},
{
name: "Hip-hop Kanye West",
title: "(Hip-Hop, Rap, Electronic) [CD][LDR] Ye (Kanye West) - Bully - 2026, FLAC (image+.cue), lossless",
wantArtist: "Ye (Kanye West)",
wantYear: 2026,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Rap Russian album",
title: "(Rap) MC Кальмар & Алкоголь После Спорта - Антибиотик - 2025, MP3, 320 kbps",
wantArtist: "MC Кальмар & Алкоголь После Спорта",
wantYear: 2025,
wantFormat: release.FormatMP3,
wantBitrate: "320 kbps",
wantParseOK: true,
},
{
name: "Rap hip-hop discography complex",
title: "(Rap/Hip-Hop) Killer Chem [Razym Garo] (при участии: AnderМаг, 03406(И.С), 2DT, Бандарад Вирши) - Официальная ДискоТрекография (3 альбома, 8 синглов, Трекография) - 2020-2026, MP3, 192-320 Kbps",
wantArtist: "Killer Chem [Razym Garo] (при участии: AnderМаг, 03406(И.С), 2DT, Бандарад Вирши)",
wantYear: 2020,
wantFormat: release.FormatMP3,
wantParseOK: true,
},
{
name: "Rap kapa album",
title: "(Rap) Капа - КАПАкалипсис - 2026, MP3, 320 kbps",
wantArtist: "Капа",
wantYear: 2026,
wantFormat: release.FormatMP3,
wantBitrate: "320 kbps",
wantParseOK: true,
},
{
name: "Rap grot album",
title: "(Rap) Грот - между катастроф - 2026, MP3, 320 kbps",
wantArtist: "Грот",
wantYear: 2026,
wantFormat: release.FormatMP3,
wantBitrate: "320 kbps",
wantParseOK: true,
},
{
name: "Rap hip-hop collection FLAC",
title: "(Rap, Hip-Hop) [CD] [WEB] † Эйсик (Asick) - Коллекция (13 релизов) - 2005-2023, FLAC (tracks+.cue), lossless",
wantArtist: "† Эйсик (Asick)",
wantYear: 2005,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Hip-hop rap WEB",
title: "(Hip-Hop/Rap) [WEB] Aarne - AA LANGUAGE (Uncensored) - 2022, FLAC (tracks), lossless",
wantArtist: "Aarne",
wantYear: 2022,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Rap fast alberto",
title: "(Rap) Фаст Альберто - Ars Longa Vita Brevis - 2026, MP3, 320 kbps",
wantArtist: "Фаст Альберто",
wantYear: 2026,
wantFormat: release.FormatMP3,
wantBitrate: "320 kbps",
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
})
}
}
@@ -1,45 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type HiResParser struct {
BaseParser
}
func NewHiResParser() *HiResParser {
return &HiResParser{}
}
func (p *HiResParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
if r.Format == release.FormatUnknown {
r.Format = release.FormatFLAC
}
r.Bitrate = "lossless"
if r.BitDepth == 0 {
if dsdMatch := dsdPattern.FindStringSubmatch(title); len(dsdMatch) >= 3 {
r.BitDepth = 1
r.Tags = append(r.Tags, dsdMatch[1]+dsdMatch[2])
}
}
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,133 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestHiResParser(t *testing.T) {
p := NewHiResParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantBitDepth int
wantSampleRate int
wantSource release.Source
wantParseOK bool
}{
{
name: "TR24 OF official release",
title: "[TR24][OF] Matteo Mancuso - Route 96 - 2026 (Progressive Rock, Jazz Fusion, Instrumental)",
wantArtist: "Matteo Mancuso",
wantYear: 2026,
wantParseOK: true,
},
{
name: "TR24 OF LDR tag",
title: "[TR24][OF][LDR] Sepultura - The Cloud Of Unknowing - 2026 (Groove Thrash Metal)",
wantArtist: "Sepultura",
wantYear: 2026,
wantParseOK: true,
},
{
name: "24bit 48kHz in title",
title: "[TR24][OF] U2 - Days Of Ash [EP] [24bit-48kHz] - 2026 (Pop Rock, Soft Rock)",
wantArtist: "U2",
wantYear: 2026,
wantBitDepth: 24,
wantSampleRate: 48000,
wantParseOK: true,
},
{
name: "LP 24/192",
title: "(Blues, R&B) [LP] [24/192] Etta James - At Last! - 1960/2026, FLAC (tracks)",
wantArtist: "Etta James",
wantYear: 1960,
wantBitDepth: 24,
wantSampleRate: 192000,
wantSource: release.SourceVinyl,
wantParseOK: true,
},
{
name: "DSD128",
title: "(Progressive rock) [LP] [1/5,64 MHz] The Neal Morse Band L. I. F. T. - 2026, DSD 128 (tracks)",
wantArtist: "The Neal Morse Band",
wantYear: 2026,
wantSource: release.SourceVinyl,
wantParseOK: true,
},
{
name: "DSD256 with label",
title: "(Jazz, Bop) [LP] [DSD256] Oscar Peterson Trio & Clark Terry - Oscar Peterson Trio + One [Acoustic Sounds Series] - 1964, dsf (tracks)",
wantArtist: "Oscar Peterson Trio & Clark Terry",
wantYear: 1964,
wantParseOK: true,
},
{
name: "24/96 modal jazz",
title: "(Modal, Jazz) [LP] [24/96] John Coltrane - The Tiberi Tapes: A Preview Of The Mythic Recordings (2026 Record Store Day) - 2026, FLAC (tracks)",
wantArtist: "John Coltrane",
wantYear: 2026,
wantBitDepth: 24,
wantSampleRate: 96000,
wantParseOK: true,
},
{
name: "2xLP compilation",
title: "(Electronic, Funk / Soul, Disco, House) [2xLP] [24/192] Various - The Many Faces Of Daft Punk - 2020( Compilation), FLAC (tracks)",
wantArtist: "Various",
wantYear: 2020,
wantBitDepth: 24,
wantSampleRate: 192000,
wantParseOK: true,
},
{
name: "SACD-R",
title: "[SACD-R][OF] Wynton Marsalis - The London Concert - 2000 (Classical)",
wantArtist: "Wynton Marsalis",
wantYear: 2000,
wantParseOK: true,
},
{
name: "SACD-R DSD",
title: "[SACD-R][DSD][OF]Scott Hamilton, Paolo Birro - Pure Imagination - 2019 (Jazz)",
wantArtist: "Scott Hamilton, Paolo Birro",
wantYear: 2019,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth {
t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth)
}
if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate {
t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
})
}
}
-38
View File
@@ -1,38 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type JazzParser struct {
BaseParser
}
func NewJazzParser() *JazzParser {
return &JazzParser{}
}
func (p *JazzParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Jazz"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,149 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestJazzParser(t *testing.T) {
p := NewJazzParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantSource release.Source
wantType release.Type
wantBitDepth int
wantSampleRate int
wantParseOK bool
}{
{
name: "Coltrane DSD256 vinyl",
title: "(Jazz, Post Bop, Modal) [LP] [DSD256] The John Coltrane Quartet - The John Coltrane Quartet Plays - 1965, dsf (tracks)",
wantArtist: "The John Coltrane Quartet",
wantYear: 1965,
wantSource: release.SourceVinyl,
wantParseOK: true,
},
{
name: "Coltrane modal jazz CD",
title: "(Modal Jazz, Hard Bop, Saxophone Jazz) [CD] John Coltrane - Coltrane Jazz - 1961, FLAC (tracks+.cue), lossless",
wantArtist: "John Coltrane",
wantYear: 1961,
wantFormat: release.FormatFLAC,
wantSource: release.SourceCD,
wantParseOK: true,
},
{
name: "TR24 bebop",
title: "[TR24][OF] Alan Broadbent - Threads of Time - 2025 (Bebop)",
wantArtist: "Alan Broadbent",
wantYear: 2025,
wantParseOK: true,
},
{
name: "Japanese jazz compilation",
title: "(Fusion, Post-Bop, Modal) [CD] VA - J Jazz Deep Modern Jazz from Japan 1969-1984 - 2018, FLAC (tracks+.cue), lossless",
wantArtist: "VA",
wantYear: 2018,
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "Fusion WEB release",
title: "(Fusion, Post-Fusion) [WEB] Tucson Modern Jazz Quartet - Eight Myths - 2025, FLAC (tracks), lossless",
wantArtist: "Tucson Modern Jazz Quartet",
wantYear: 2025,
wantSource: release.SourceWEB,
wantParseOK: true,
},
{
name: "Miles Davis live vinyl 24/96",
title: "(Jazz Rock, Fusion, Psychedelic) [2xLP] [24/96] Miles Davis - Live in Tokyo 1975 - 2015, FLAC (image+.cue)",
wantArtist: "Miles Davis",
wantYear: 2015,
wantType: release.TypeLive,
wantBitDepth: 24,
wantSampleRate: 96000,
wantParseOK: true,
},
{
name: "Miles Davis Plugged Nickel 24/192",
title: "(Jazz) [LP] [24/192] Miles Davis - Live At The Plugged Nickel December 22 1965 - 2013, FLAC (image+.cue)",
wantArtist: "Miles Davis",
wantYear: 2013,
wantType: release.TypeLive,
wantBitDepth: 24,
wantSampleRate: 192000,
wantParseOK: true,
},
{
name: "Contemporary jazz CD",
title: "(Post-Bop, Contemporary Jazz) [CD] Billy Hart - Multidirectional - 2025, FLAC (tracks+.cue), lossless",
wantArtist: "Billy Hart",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Herbie Mann live mono vinyl",
title: "(Jazz, Hard Bop) [LP] [24/192] Herbie Mann - Herbie Mann At The Village Gate - 1962, FLAC (image+.cue)",
wantArtist: "Herbie Mann",
wantYear: 1962,
wantType: release.TypeLive,
wantBitDepth: 24,
wantSampleRate: 192000,
wantParseOK: true,
},
{
name: "Smooth jazz WEB",
title: "(Smooth Jazz) [WEB] VA - Smooth Jazz Plays Your Favorite Hits - 2006, FLAC (tracks), lossless",
wantArtist: "VA",
wantYear: 2006,
wantSource: release.SourceWEB,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth {
t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth)
}
if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate {
t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate)
}
})
}
}
@@ -1,33 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type LabelPacksParser struct {
BaseParser
}
func NewLabelPacksParser() *LabelPacksParser {
return &LabelPacksParser{}
}
func (p *LabelPacksParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = release.TypeCollection
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
if r.Label == "" {
p.AddError(r, "failed to extract label name")
}
return r
}
@@ -1,146 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestLabelPacksParser(t *testing.T) {
p := NewLabelPacksParser()
tests := []struct {
name string
title string
wantLabel string
wantYear int
wantYearEnd int
wantReleaseCount int
wantFormat release.AudioFormat
wantParseOK bool
}{
{
name: "standard label pack",
title: "(Drum & Bass) [WEB] Label: Metalheadz (370 релизов), 1994-2025, FLAC (tracks), lossless",
wantLabel: "Metalheadz",
wantYear: 1994,
wantYearEnd: 2025,
wantReleaseCount: 370,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "label with part number",
title: "(Trance, House) [WEB] Label: Black Hole Recordings Part 3 (401 Releases) - 2009-2023, FLAC (tracks / images), lossless",
wantLabel: "Black Hole Recordings Part 3",
wantYear: 2009,
wantYearEnd: 2023,
wantReleaseCount: 401,
wantParseOK: true,
},
{
name: "small label",
title: "(Trance) [WEB, CD] Label: Solaris Recordings (7 Releases) - 2005-2014, FLAC (tracks, tracks+.cue), lossless",
wantLabel: "Solaris Recordings",
wantYear: 2005,
wantYearEnd: 2014,
wantParseOK: true,
},
{
name: "techno label with brackets",
title: "(Techno, IDM, Experimental) [WEB,CD] Label: Stroboscopic Artefacts (96 Releases) - 2009-2022, FLAC (tracks) (tracks+.cue), lossless",
wantLabel: "Stroboscopic Artefacts",
wantYear: 2009,
wantYearEnd: 2022,
wantReleaseCount: 96,
wantParseOK: true,
},
{
name: "multi-genre label",
title: "(Techno, Ambient, IDM, Experimental, Drum n Bass) [WEB,CD] Label: Auxiliary (65 Releases) - 2010-2021, FLAC (tracks) (tracks+.cue), lossless",
wantLabel: "Auxiliary",
wantYear: 2010,
wantYearEnd: 2021,
wantReleaseCount: 65,
wantParseOK: true,
},
{
name: "Russian release count",
title: "(Techno, Minimal, Deep Tech, Melodic House & Techno) [WEB] Label: FCKNG SERIOUS (121 релиз), 2015-2025, FLAC (tracks, image), lossless",
wantLabel: "FCKNG SERIOUS",
wantYear: 2015,
wantYearEnd: 2025,
wantReleaseCount: 121,
wantParseOK: true,
},
{
name: "progressive house label",
title: "(Progressive House, Trance, Techno) [WEB] Label: Bedrock Records (519 релизов), 1999-2025, (FLAC) lossless (tracks, image)",
wantLabel: "Bedrock Records",
wantYear: 1999,
wantYearEnd: 2025,
wantReleaseCount: 519,
wantParseOK: true,
},
{
name: "large techno label",
title: "(Techno) [WEB,CD] Label: Planet Rhythm Records (443 Releases) - 1994-2021, FLAC (tracks) (tracks+.cue, image+.cue), lossless",
wantLabel: "Planet Rhythm Records",
wantYear: 1994,
wantYearEnd: 2021,
wantReleaseCount: 443,
wantParseOK: true,
},
{
name: "label with featured artists",
title: "(Trance, Breaks, House) [WEB] Label: Digital Emotions (47 Releases) (Incl. Fonarev pres. F13, Poshout, Second Sine & etc.) - 2010-2025, FLAC (tracks), lossless",
wantLabel: "Digital Emotions",
wantYear: 2010,
wantYearEnd: 2025,
wantParseOK: true,
},
{
name: "bondage music",
title: "(Deep House, Minimal) [WEB] Label: Bondage Music (173 релиза), 2006-2025, (FLAC) lossless (tracks, image)",
wantLabel: "Bondage Music",
wantYear: 2006,
wantYearEnd: 2025,
wantReleaseCount: 173,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantLabel != "" && r.Label != tt.wantLabel {
t.Errorf("Label = %q, want %q", r.Label, tt.wantLabel)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantYearEnd != 0 && r.YearEnd != tt.wantYearEnd {
t.Errorf("YearEnd = %d, want %d", r.YearEnd, tt.wantYearEnd)
}
if tt.wantReleaseCount != 0 && r.ReleaseCount != tt.wantReleaseCount {
t.Errorf("ReleaseCount = %d, want %d", r.ReleaseCount, tt.wantReleaseCount)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if r.Type != release.TypeCollection {
t.Errorf("Type = %v, want Collection", r.Type)
}
})
}
}
@@ -1,37 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type LosslessParser struct {
BaseParser
}
func NewLosslessParser() *LosslessParser {
return &LosslessParser{}
}
func (p *LosslessParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Format == release.FormatUnknown {
r.Format = release.FormatFLAC
}
r.Bitrate = "lossless"
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,143 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestLosslessParser(t *testing.T) {
p := NewLosslessParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantSource release.Source
wantRipType string
wantParseOK bool
}{
{
name: "standard CD FLAC image",
title: "(Rock) [CD] Thin Lizzy - Acoustic Sessions - 2024 (Decca Records EU 2025), FLAC (image+.cue), lossless",
wantArtist: "Thin Lizzy",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantSource: release.SourceCD,
wantRipType: "image+.cue",
wantParseOK: true,
},
{
name: "WEB release tracks",
title: "(Progressive Rock) [WEB] Opeth - In Cauda Venenum (Extended Edition) - 2019/2022, FLAC (tracks), lossless",
wantArtist: "Opeth",
wantYear: 2019,
wantFormat: release.FormatFLAC,
wantSource: release.SourceWEB,
wantRipType: "tracks",
wantParseOK: true,
},
{
name: "APE format",
title: "(Jazz) [CD] Miles Davis - Kind of Blue - 1959, APE (image+.cue), lossless",
wantArtist: "Miles Davis",
wantYear: 1959,
wantFormat: release.FormatAPE,
wantSource: release.SourceCD,
wantParseOK: true,
},
{
name: "tracks+cue format",
title: "(Classic Rock) [CD] The Who - Who Are You (Super Deluxe Edition) - 2025, FLAC (tracks+cue), lossless",
wantArtist: "The Who",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantRipType: "tracks+cue",
wantParseOK: true,
},
{
name: "multi-disc set",
title: "(Hard Rock, Glam Rock, Progressive Rock, Art Rock, Heavy Metal) [CD] Queen Queen I (2 CD) 2024 , FLAC (image+.cue), lossless",
wantArtist: "Queen",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Japan release",
title: "(Pop-Rock Soft-Rock) [CD] Sting - The Soul Cages (Expanded Edition) - 2025 [Japan], FLAC (image+.cue), lossless",
wantArtist: "Sting",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "WavPack format",
title: "(Progressive Rock) [CD] Yes - Close to the Edge - 1972, WV (image+.cue), lossless",
wantArtist: "Yes",
wantYear: 1972,
wantFormat: release.FormatWavPack,
wantParseOK: true,
},
{
name: "default to FLAC when format not specified",
title: "(Rock) [CD] Pink Floyd - The Wall - 1979 (image+.cue), lossless",
wantArtist: "Pink Floyd",
wantYear: 1979,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "heavy metal WEB",
title: "(Heavy Metal) [WEB] Heaven & Hell - Breaking Out Of Heaven - 2026, FLAC (tracks), lossless",
wantArtist: "Heaven & Hell",
wantYear: 2026,
wantSource: release.SourceWEB,
wantParseOK: true,
},
{
name: "melodic rock WEB",
title: "(Melodic Rock, Progressive Rock) [WEB] James LaBrie - Beautiful Shade Of Grey - 2022, FLAC (tracks), lossless",
wantArtist: "James LaBrie",
wantYear: 2022,
wantSource: release.SourceWEB,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
}
if r.Bitrate != "lossless" {
t.Errorf("Bitrate = %q, want lossless", r.Bitrate)
}
})
}
}
@@ -1,36 +0,0 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type LossyParser struct {
BaseParser
}
func NewLossyParser() *LossyParser {
return &LossyParser{}
}
func (p *LossyParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Format == release.FormatUnknown {
r.Format = release.FormatMP3
}
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
@@ -1,136 +0,0 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestLossyParser(t *testing.T) {
p := NewLossyParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantBitrate string
wantType release.Type
wantParseOK bool
}{
{
name: "VBR V0",
title: "(Pop) VA - Pop Classics Top 100 - 2012, MP3, VBR V0",
wantArtist: "VA",
wantYear: 2012,
wantFormat: release.FormatMP3,
wantBitrate: "V0",
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "VBR V0 kbps suffix",
title: "(Pop/Rock) VA - 101 Ultimate 80's (5 CD) - 2011, MP3 (tracks), VBR V0 kbps",
wantArtist: "VA",
wantYear: 2011,
wantFormat: release.FormatMP3,
wantBitrate: "V0",
wantParseOK: true,
},
{
name: "VBR V1",
title: "(Rock) VA - Greatest Ever! Rock The Definitive Collection (3 CD) - 2006, MP3 (tracks), VBR V1 kbps",
wantArtist: "VA",
wantYear: 2006,
wantBitrate: "V1",
wantParseOK: true,
},
{
name: "VBR V2",
title: "(Classic Rock) VA - Twist & Shout - 2005, MP3, VBR V2",
wantArtist: "VA",
wantYear: 2005,
wantBitrate: "V2",
wantParseOK: true,
},
{
name: "VBR range",
title: "(Pop, Rock) VA - The Essential 1980s - 2010, MP3 (tracks), VBR 192-320 kbps",
wantArtist: "VA",
wantYear: 2010,
wantBitrate: "VBR 192-320 kbps",
wantParseOK: true,
},
{
name: "CBR 320",
title: "(Pop) VA - Bravo Hits, Vol. 128 [2 CD] - 2025, MP3, 320 kbps",
wantArtist: "VA",
wantYear: 2025,
wantBitrate: "320 kbps",
wantParseOK: true,
},
{
name: "CBR 256",
title: "(Rock'n'Roll) VA - Rock-n-roll The Best Hits - 2005, MP3 (tracks), 256 kbps",
wantArtist: "VA",
wantYear: 2005,
wantFormat: release.FormatMP3,
wantBitrate: "256 kbps",
wantParseOK: true,
},
{
name: "year range in title",
title: "(Pop) VA - Bravo Hits vol. 31-59 - 2000-2007, MP3, VBR 192-320 kbps",
wantArtist: "VA",
wantYear: 2000,
wantParseOK: true,
},
{
name: "discography in lossy",
title: "(Alternative Metal / Post-Grunge) Breaking Benjamin - Discography: 23 Releases, 2001-2024, MP3, VBR V0/320 kbps",
wantArtist: "Breaking Benjamin",
wantYear: 2001,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "bootleg release",
title: "(Eurodance) VA - Beat Mix Eurodance Vol 1-3 (Bootlegs) - 2009-2011, MP3 (image), VBR V2 / V0",
wantArtist: "VA",
wantYear: 2009,
wantType: release.TypeBootleg,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More