Compare commits
19 Commits
758a4b909a
...
3e8b8153b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e8b8153b6 | |||
| 69752bd6a2 | |||
| 93821ab214 | |||
| be859e87c0 | |||
| e5bdf2c4ce | |||
| 5a5660bf21 | |||
| 52e81faedd | |||
| 7582279077 | |||
| f52e9abb0a | |||
| 7d11b729a5 | |||
| eab92dd40b | |||
| ad03caa3f4 | |||
| 24f355c5ae | |||
| f5e2f764b5 | |||
| 6320f37240 | |||
| 2740585261 | |||
| 1e8506f146 | |||
| 7fa859e815 | |||
| ef75b9bfba |
@@ -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"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
|
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal"
|
"homelab.lan/music-agregator/internal"
|
||||||
|
"homelab.lan/music-agregator/internal/analysis"
|
||||||
"homelab.lan/music-agregator/internal/config"
|
"homelab.lan/music-agregator/internal/config"
|
||||||
"homelab.lan/music-agregator/internal/database"
|
"homelab.lan/music-agregator/internal/database"
|
||||||
"homelab.lan/music-agregator/internal/hello"
|
"homelab.lan/music-agregator/internal/hello"
|
||||||
@@ -82,12 +83,16 @@ type riverSetup struct {
|
|||||||
cacheRefreshWorker *indexer.CacheRefreshWorker
|
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{}
|
cacheWorker := &indexer.CacheRefreshWorker{}
|
||||||
pollWorker := &workers.PollDownloadWorker{
|
pollWorker := &workers.PollDownloadWorker{
|
||||||
Downloads: database.NewDownloadRepository(db.Pool),
|
Downloads: database.NewDownloadRepository(db.Pool),
|
||||||
DownloadFiles: database.NewDownloadFileRepository(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()
|
riverWorkers := river.NewWorkers()
|
||||||
@@ -155,9 +160,20 @@ func serveGrpc(config config.Config) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db := setupDatabase(ctx, config)
|
db := setupDatabase(ctx, config)
|
||||||
defer db.Close()
|
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 {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed to create MusicAgregatorServer")
|
log.Fatal().Err(err).Msg("failed to create MusicAgregatorServer")
|
||||||
}
|
}
|
||||||
@@ -165,10 +181,7 @@ func serveGrpc(config config.Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed to create IndexerServer")
|
log.Fatal().Err(err).Msg("failed to create IndexerServer")
|
||||||
}
|
}
|
||||||
torrentServer, err := torrent.NewTorrentServer(config)
|
torrentServer := torrent.NewTorrentServer(torrentClient)
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to create TorrentServer")
|
|
||||||
}
|
|
||||||
metadataServer, err := metadata.NewMetadataServer(config)
|
metadataServer, err := metadata.NewMetadataServer(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed to create MetadataServer")
|
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';
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -2,7 +2,21 @@ module homelab.lan/music-agregator
|
|||||||
|
|
||||||
go 1.26.2
|
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 (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
@@ -24,7 +38,6 @@ require (
|
|||||||
github.com/anacrolix/multiless v0.4.0 // indirect
|
github.com/anacrolix/multiless v0.4.0 // indirect
|
||||||
github.com/anacrolix/stm v0.5.0 // indirect
|
github.com/anacrolix/stm v0.5.0 // indirect
|
||||||
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 // 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/upnp v0.1.4 // indirect
|
||||||
github.com/anacrolix/utp v0.1.0 // indirect
|
github.com/anacrolix/utp v0.1.0 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
@@ -42,6 +55,7 @@ require (
|
|||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/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-connections v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||||
@@ -53,23 +67,23 @@ require (
|
|||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/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/btree v1.1.2 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.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/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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/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/minio/sha256-simd v1.0.0 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
@@ -106,25 +120,18 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/protolambda/ctxlock v0.1.0 // indirect
|
github.com/protolambda/ctxlock v0.1.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 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/rivershared v0.35.1 // indirect
|
||||||
github.com/riverqueue/river/rivertype v0.35.1 // indirect
|
github.com/riverqueue/river/rivertype v0.35.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
|
||||||
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
|
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
|
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect
|
|
||||||
github.com/tidwall/btree v1.8.1 // indirect
|
github.com/tidwall/btree v1.8.1 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.2.0 // indirect
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
@@ -147,7 +154,6 @@ require (
|
|||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // 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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
lukechampine.com/blake3 v1.1.6 // indirect
|
lukechampine.com/blake3 v1.1.6 // indirect
|
||||||
modernc.org/libc v1.22.3 // indirect
|
modernc.org/libc v1.22.3 // indirect
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oX
|
|||||||
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
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 h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||||
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
|
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
|
||||||
@@ -17,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/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/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/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 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
|
||||||
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
|
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-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
@@ -46,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 h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=
|
||||||
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=
|
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.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 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.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
|
||||||
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
|
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
|
||||||
@@ -114,11 +125,15 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
|
|||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
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/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
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 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
@@ -138,6 +153,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
|
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
|
||||||
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/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/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-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
@@ -161,9 +178,13 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
|||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
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/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
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/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-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
@@ -207,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 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
|
||||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
|
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/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/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.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
|
||||||
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
|
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
|
||||||
@@ -214,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.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 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
|
||||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -227,6 +256,7 @@ 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.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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
@@ -238,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/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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
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.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -246,6 +275,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/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 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
@@ -255,6 +288,14 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
|
|||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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/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 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
@@ -382,17 +423,18 @@ 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/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 h1:7SfjZ3Hkr7gRjItMHAUzJBAHIqx41yS/4yjVPQVtNfM=
|
||||||
github.com/riverqueue/river/rivertype v0.35.1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ=
|
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.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.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.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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
|
||||||
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
|
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 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
||||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
@@ -412,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.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.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.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.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
@@ -424,6 +468,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
|||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/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 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
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 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
|
||||||
@@ -452,6 +498,8 @@ github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPyS
|
|||||||
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
|
github.com/yuin/goldmark v1.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.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
@@ -482,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-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-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-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.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.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
@@ -494,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-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-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/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.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.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
@@ -509,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-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-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-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-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-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
@@ -525,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-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-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-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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -544,6 +598,7 @@ golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-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-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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -565,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.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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -573,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.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.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.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 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
@@ -586,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-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-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-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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
@@ -633,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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
|
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
|
||||||
@@ -645,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/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
|
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
|
||||||
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
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 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
|
||||||
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
|
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ type Config struct {
|
|||||||
Url string `yaml:"url"`
|
Url string `yaml:"url"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
|
ContainerName string `yaml:"container_name"`
|
||||||
} `yaml:"torrent"`
|
} `yaml:"torrent"`
|
||||||
|
|
||||||
Metadata struct {
|
Metadata struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +61,46 @@ func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
|
|||||||
return nil
|
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) {
|
func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string) (*Album, error) {
|
||||||
a := &Album{}
|
a := &Album{}
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ func (r *DownloadRepository) SetFailed(ctx context.Context, id string, errorMsg
|
|||||||
return nil
|
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) {
|
func (r *DownloadRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Download, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
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
|
`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
|
||||||
@@ -141,6 +161,43 @@ func (r *DownloadRepository) GetActive(ctx context.Context) ([]*Download, error)
|
|||||||
return downloads, nil
|
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) {
|
func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) {
|
||||||
var exists bool
|
var exists bool
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||||
"homelab.lan/music-agregator/internal/release"
|
"homelab.lan/music-agregator/internal/release"
|
||||||
"homelab.lan/music-agregator/internal/tracker/rutracker"
|
"homelab.lan/music-agregator/internal/tracker"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
@@ -90,15 +90,13 @@ func (sr *SearchResponse) ToProto() *pb.SearchResponse {
|
|||||||
return &pb.SearchResponse{Result: pbItems}
|
return &pb.SearchResponse{Result: pbItems}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var genericParser = tracker.NewGenericParser()
|
||||||
rutrackerParserFactory = rutracker.NewRuTrackerParserFactory()
|
|
||||||
)
|
|
||||||
|
|
||||||
func (sr *SearchResult) ToSearchResponse() *SearchResponse {
|
func (sr *SearchResult) ToSearchResponse() *SearchResponse {
|
||||||
var items []*SearchItemResult
|
var items []*SearchItemResult
|
||||||
|
|
||||||
for _, item := range sr.Items {
|
for _, item := range sr.Items {
|
||||||
rel := rutrackerParserFactory.GetParser(item.Categories).Parse(item.Title)
|
rel := genericParser.Parse(item.Title)
|
||||||
|
|
||||||
log.Trace().
|
log.Trace().
|
||||||
Str("tracker", item.JackettIndexer.ID).
|
Str("tracker", item.JackettIndexer.ID).
|
||||||
|
|||||||
@@ -54,6 +54,30 @@ func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistExternalID
|
|||||||
return resp.GetAlbums(), nil
|
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) {
|
func (s *MetadataService) GetAlbumTracks(ctx context.Context, albumExternalID string) ([]*metadataPb.Track, error) {
|
||||||
resp, err := s.client.GetAlbumTracks(ctx, &metadataPb.GetAlbumTracksRequest{
|
resp, err := s.client.GetAlbumTracks(ctx, &metadataPb.GetAlbumTracksRequest{
|
||||||
AlbumId: albumExternalID,
|
AlbumId: albumExternalID,
|
||||||
@@ -117,6 +141,40 @@ func (s *MetadataService) PersistAlbum(ctx context.Context, album *metadataPb.Al
|
|||||||
s.PersistAlbumForArtist(ctx, album, "", state)
|
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) {
|
func (s *MetadataService) PersistAlbumForArtist(ctx context.Context, album *metadataPb.Album, artistDBID string, state database.MonitorState) {
|
||||||
if artistDBID == "" {
|
if artistDBID == "" {
|
||||||
if len(album.GetArtists()) > 0 {
|
if len(album.GetArtists()) > 0 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+279
-3
@@ -2,35 +2,59 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/riverqueue/river"
|
"github.com/riverqueue/river"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
|
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
|
||||||
"homelab.lan/music-agregator/internal/config"
|
"homelab.lan/music-agregator/internal/config"
|
||||||
"homelab.lan/music-agregator/internal/database"
|
"homelab.lan/music-agregator/internal/database"
|
||||||
|
"homelab.lan/music-agregator/internal/eventbus"
|
||||||
|
"homelab.lan/music-agregator/internal/torrent"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MusicAgregatorServer struct {
|
type MusicAgregatorServer struct {
|
||||||
service *MusicAgregatorService
|
service *MusicAgregatorService
|
||||||
|
bus *eventbus.EventBus
|
||||||
|
registry *WorkflowRegistry
|
||||||
pb.UnimplementedMusicAgregatorServiceServer
|
pb.UnimplementedMusicAgregatorServiceServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorServer, error) {
|
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, db)
|
service, err := NewMusicAgregatorService(cfg, riverClient, torrentClient, pathMapper, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to create MusicAgregatorService")
|
log.Err(err).Msg("failed to create MusicAgregatorService")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
bus := eventbus.New()
|
||||||
return &MusicAgregatorServer{
|
return &MusicAgregatorServer{
|
||||||
service: service,
|
service: service,
|
||||||
|
bus: bus,
|
||||||
|
registry: NewWorkflowRegistry(bus),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMusicAgregatorServerWithService(service *MusicAgregatorService) *MusicAgregatorServer {
|
func NewMusicAgregatorServerWithService(service *MusicAgregatorService) *MusicAgregatorServer {
|
||||||
return &MusicAgregatorServer{service: service}
|
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) {
|
func (s *MusicAgregatorServer) GetArtists(ctx context.Context, req *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) {
|
||||||
@@ -45,6 +69,258 @@ func (s *MusicAgregatorServer) MonitorAlbum(ctx context.Context, req *pb.Monitor
|
|||||||
return s.service.MonitorAlbum(ctx, req)
|
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) {
|
func (s *MusicAgregatorServer) Register(server *grpc.Server) {
|
||||||
pb.RegisterMusicAgregatorServiceServer(server, s)
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+344
-29
@@ -6,15 +6,18 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/riverqueue/river"
|
"github.com/riverqueue/river"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
||||||
pb "homelab.lan/music-agregator/gen/music_agregator/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/config"
|
||||||
"homelab.lan/music-agregator/internal/database"
|
"homelab.lan/music-agregator/internal/database"
|
||||||
"homelab.lan/music-agregator/internal/indexer"
|
"homelab.lan/music-agregator/internal/indexer"
|
||||||
@@ -38,13 +41,21 @@ type MusicAgregatorService struct {
|
|||||||
torrentClient torrent.TorrentClient
|
torrentClient torrent.TorrentClient
|
||||||
magnetResolver torrentParser.Resolver
|
magnetResolver torrentParser.Resolver
|
||||||
riverClient *river.Client[pgx.Tx]
|
riverClient *river.Client[pgx.Tx]
|
||||||
|
pathMapper *torrent.PathMapper
|
||||||
torrents *database.TorrentRepository
|
torrents *database.TorrentRepository
|
||||||
downloads *database.DownloadRepository
|
downloads *database.DownloadRepository
|
||||||
artists *database.ArtistRepository
|
artists *database.ArtistRepository
|
||||||
downloadFiles *database.DownloadFileRepository
|
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)
|
idx, err := indexer.NewIndexerService(cfg, riverClient, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to create IndexerService")
|
log.Err(err).Msg("failed to create IndexerService")
|
||||||
@@ -63,11 +74,7 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
torrentClient, err := torrent.NewTorrentClient(cfg)
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to create torrent client")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &MusicAgregatorService{
|
return &MusicAgregatorService{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@@ -76,10 +83,18 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
|
|||||||
torrentClient: torrentClient,
|
torrentClient: torrentClient,
|
||||||
magnetResolver: magnetResolver,
|
magnetResolver: magnetResolver,
|
||||||
riverClient: riverClient,
|
riverClient: riverClient,
|
||||||
|
pathMapper: pathMapper,
|
||||||
torrents: database.NewTorrentRepository(db.Pool),
|
torrents: database.NewTorrentRepository(db.Pool),
|
||||||
downloads: database.NewDownloadRepository(db.Pool),
|
downloads: database.NewDownloadRepository(db.Pool),
|
||||||
artists: database.NewArtistRepository(db.Pool),
|
artists: database.NewArtistRepository(db.Pool),
|
||||||
downloadFiles: database.NewDownloadFileRepository(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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,27 +104,78 @@ func NewMusicAgregatorServiceWithDeps(
|
|||||||
torrentClient torrent.TorrentClient,
|
torrentClient torrent.TorrentClient,
|
||||||
magnetResolver torrentParser.Resolver,
|
magnetResolver torrentParser.Resolver,
|
||||||
riverClient *river.Client[pgx.Tx],
|
riverClient *river.Client[pgx.Tx],
|
||||||
|
pathMapper *torrent.PathMapper,
|
||||||
db *database.DB,
|
db *database.DB,
|
||||||
) *MusicAgregatorService {
|
) *MusicAgregatorService {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
return &MusicAgregatorService{
|
return &MusicAgregatorService{
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
indexer: searcher,
|
indexer: searcher,
|
||||||
torrentClient: torrentClient,
|
torrentClient: torrentClient,
|
||||||
magnetResolver: magnetResolver,
|
magnetResolver: magnetResolver,
|
||||||
riverClient: riverClient,
|
riverClient: riverClient,
|
||||||
|
pathMapper: pathMapper,
|
||||||
torrents: database.NewTorrentRepository(db.Pool),
|
torrents: database.NewTorrentRepository(db.Pool),
|
||||||
downloads: database.NewDownloadRepository(db.Pool),
|
downloads: database.NewDownloadRepository(db.Pool),
|
||||||
artists: database.NewArtistRepository(db.Pool),
|
artists: database.NewArtistRepository(db.Pool),
|
||||||
downloadFiles: database.NewDownloadFileRepository(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() {
|
func (s *MusicAgregatorService) Close() {
|
||||||
|
if s.shutdownCancel != nil {
|
||||||
|
s.shutdownCancel()
|
||||||
|
}
|
||||||
if closer, ok := s.magnetResolver.(interface{ Close() }); ok {
|
if closer, ok := s.magnetResolver.(interface{ Close() }); ok {
|
||||||
closer.Close()
|
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) {
|
func (service *MusicAgregatorService) GetArtists(ctx context.Context, _ *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) {
|
||||||
dbArtists, err := service.artists.GetAll(ctx)
|
dbArtists, err := service.artists.GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,14 +183,20 @@ func (service *MusicAgregatorService) GetArtists(ctx context.Context, _ *pb.GetA
|
|||||||
return nil, fmt.Errorf("listing artists: %w", err)
|
return nil, fmt.Errorf("listing artists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
artists := make([]*pb.ArtistSummary, 0, len(dbArtists))
|
artists := make([]*pb.ArtistSummary, len(dbArtists))
|
||||||
for _, a := range dbArtists {
|
var mu sync.Mutex
|
||||||
albums, err := service.buildAlbumsForArtist(ctx, a)
|
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 {
|
if err != nil {
|
||||||
log.Warn().Err(err).Str("artist", a.Name).Msg("failed to build album details, returning artist without albums")
|
log.Warn().Err(err).Str("artist", a.Name).Msg("failed to build album details, returning artist without albums")
|
||||||
}
|
}
|
||||||
|
|
||||||
artists = append(artists, &pb.ArtistSummary{
|
summary := &pb.ArtistSummary{
|
||||||
Id: a.ID,
|
Id: a.ID,
|
||||||
ExternalId: a.ExternalID,
|
ExternalId: a.ExternalID,
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
@@ -134,21 +206,36 @@ func (service *MusicAgregatorService) GetArtists(ctx context.Context, _ *pb.GetA
|
|||||||
ImageUrl: a.ImageURL,
|
ImageUrl: a.ImageURL,
|
||||||
MonitorState: toProtoMonitorState(a.MonitorState),
|
MonitorState: toProtoMonitorState(a.MonitorState),
|
||||||
Albums: albums,
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
artists[i] = summary
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = g.Wait()
|
||||||
|
|
||||||
return &pb.GetArtistsResponse{Artists: artists}, nil
|
return &pb.GetArtistsResponse{Artists: artists}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *MusicAgregatorService) buildAlbumsForArtist(ctx context.Context, artist *database.Artist) ([]*pb.AlbumDetail, error) {
|
func (service *MusicAgregatorService) buildAlbumsForArtist(ctx context.Context, artist *database.Artist) ([]*pb.AlbumDetail, error) {
|
||||||
metadataAlbums, err := service.metadata.GetArtistAlbums(ctx, artist.ExternalID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetching metadata albums: %w", err)
|
return nil, fmt.Errorf("fetching metadata albums: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ma := range metadataAlbums {
|
service.metadata.PersistAlbumsForArtist(ctx, metadataAlbums, artist.ID, database.Unmonitored)
|
||||||
service.metadata.PersistAlbumForArtist(ctx, ma, artist.ID, database.Unmonitored)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbAlbums, err := service.metadata.GetAlbumsByArtistID(ctx, artist.ID)
|
dbAlbums, err := service.metadata.GetAlbumsByArtistID(ctx, artist.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,10 +244,14 @@ func (service *MusicAgregatorService) buildAlbumsForArtist(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbAlbumsByExternalID := make(map[string]*database.Album, len(dbAlbums))
|
dbAlbumsByExternalID := make(map[string]*database.Album, len(dbAlbums))
|
||||||
|
albumIDs := make([]string, 0, len(dbAlbums))
|
||||||
for _, a := range dbAlbums {
|
for _, a := range dbAlbums {
|
||||||
dbAlbumsByExternalID[a.ExternalID] = a
|
dbAlbumsByExternalID[a.ExternalID] = a
|
||||||
|
albumIDs = append(albumIDs, a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadsByAlbumID, _ := service.downloads.GetLatestByAlbumIDs(ctx, albumIDs)
|
||||||
|
|
||||||
albums := make([]*pb.AlbumDetail, 0, len(metadataAlbums))
|
albums := make([]*pb.AlbumDetail, 0, len(metadataAlbums))
|
||||||
for _, ma := range metadataAlbums {
|
for _, ma := range metadataAlbums {
|
||||||
detail := &pb.AlbumDetail{
|
detail := &pb.AlbumDetail{
|
||||||
@@ -184,14 +275,12 @@ func (service *MusicAgregatorService) buildAlbumsForArtist(ctx context.Context,
|
|||||||
detail.Id = dbAlbum.ID
|
detail.Id = dbAlbum.ID
|
||||||
detail.MonitorState = toProtoMonitorState(dbAlbum.MonitorState)
|
detail.MonitorState = toProtoMonitorState(dbAlbum.MonitorState)
|
||||||
|
|
||||||
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
|
if d, ok := downloadsByAlbumID[dbAlbum.ID]; ok {
|
||||||
if err == nil && len(downloads) > 0 {
|
|
||||||
best := downloads[0]
|
|
||||||
detail.Download = &pb.DownloadInfo{
|
detail.Download = &pb.DownloadInfo{
|
||||||
State: best.State,
|
State: d.State,
|
||||||
Format: best.Format,
|
Format: d.Format,
|
||||||
Quality: best.Quality,
|
Quality: d.Quality,
|
||||||
SavePath: derefStr(best.SavePath),
|
SavePath: derefStr(d.SavePath),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -210,6 +299,15 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
|
|||||||
return nil, fmt.Errorf("album not found: %w", err)
|
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)
|
metadataAlbum, err := service.metadata.GetAlbum(ctx, dbAlbum.ExternalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get album from metadata")
|
log.Error().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get album from metadata")
|
||||||
@@ -272,6 +370,44 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
|
|||||||
dbTracksByExternalID[t.ExternalID] = t
|
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))
|
tracks := make([]*pb.TrackDetail, 0, len(metadataTracks))
|
||||||
for _, mt := range metadataTracks {
|
for _, mt := range metadataTracks {
|
||||||
td := &pb.TrackDetail{
|
td := &pb.TrackDetail{
|
||||||
@@ -293,24 +429,95 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
|
|||||||
|
|
||||||
if dbTrack, ok := dbTracksByExternalID[mt.GetId()]; ok {
|
if dbTrack, ok := dbTracksByExternalID[mt.GetId()]; ok {
|
||||||
td.Id = dbTrack.ID
|
td.Id = dbTrack.ID
|
||||||
if df, ok := downloadFilesByTrackID[dbTrack.ID]; ok {
|
|
||||||
td.File = &pb.TrackFile{
|
if tr, ok := trackReleasesByTrackID[dbTrack.ID]; ok {
|
||||||
Path: df.FilePath,
|
td.FilePath = tr.FilePath
|
||||||
Format: df.FileType,
|
td.FileSize = tr.FileSize
|
||||||
Size: df.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)
|
tracks = append(tracks, td)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.GetAlbumResponse{
|
return &pb.AlbumInfo{
|
||||||
Album: album,
|
Album: album,
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
}, nil
|
}, 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) {
|
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
|
||||||
album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId())
|
album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -481,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 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")
|
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add magnet to client")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -494,7 +706,7 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
|
|||||||
if err := service.torrentClient.AddTorrent(torrent.TorrentFile{
|
if err := service.torrentClient.AddTorrent(torrent.TorrentFile{
|
||||||
Filename: best.rel.Album + ".torrent",
|
Filename: best.rel.Album + ".torrent",
|
||||||
Data: best.torrentData,
|
Data: best.torrentData,
|
||||||
}); err != nil {
|
}, savePath); err != nil {
|
||||||
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add torrent to client")
|
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add torrent to client")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -713,3 +925,106 @@ func downloadTorrentData(url string) ([]byte, error) {
|
|||||||
|
|
||||||
return data, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type TorrentClient interface {
|
|||||||
Login(username string, password string) (string, error)
|
Login(username string, password string) (string, error)
|
||||||
List() ([]TorrentInfo, error)
|
List() ([]TorrentInfo, error)
|
||||||
Find(opts FindOptions) ([]TorrentInfo, error)
|
Find(opts FindOptions) ([]TorrentInfo, error)
|
||||||
AddTorrent(file TorrentFile) error
|
AddTorrent(file TorrentFile, savePath string) error
|
||||||
AddMagnet(magnetURI string) error
|
AddMagnet(magnetURI string, savePath string) error
|
||||||
|
DeleteTorrent(hash string) error
|
||||||
|
DefaultSavePath() (string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -173,8 +173,8 @@ func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
|
func (c *QbittorrentClient) AddTorrent(file TorrentFile, savePath string) error {
|
||||||
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent file")
|
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Str("save_path", savePath).Msg("qbittorrent adding torrent file")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
writer := multipart.NewWriter(&buf)
|
writer := multipart.NewWriter(&buf)
|
||||||
@@ -190,6 +190,12 @@ func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
|
|||||||
return fmt.Errorf("writing torrent data: %w", err)
|
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 {
|
if err := writer.Close(); err != nil {
|
||||||
return fmt.Errorf("closing multipart writer: %w", err)
|
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)
|
return c.doAdd(req, file.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *QbittorrentClient) AddMagnet(magnetURI string) error {
|
func (c *QbittorrentClient) AddMagnet(magnetURI string, savePath string) error {
|
||||||
truncated := magnetURI
|
truncated := magnetURI
|
||||||
if len(truncated) > 80 {
|
if len(truncated) > 80 {
|
||||||
truncated = 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}}
|
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()))
|
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", strings.NewReader(data.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("qbittorrent creating magnet add request failed")
|
log.Error().Err(err).Msg("qbittorrent creating magnet add request failed")
|
||||||
@@ -303,3 +312,62 @@ func (t *QbittorrentListItem) toTorrentInfo() TorrentInfo {
|
|||||||
Availability: t.Availability,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
|
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
|
||||||
"homelab.lan/music-agregator/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TorrentServer struct {
|
type TorrentServer struct {
|
||||||
@@ -15,14 +14,8 @@ type TorrentServer struct {
|
|||||||
pb.UnimplementedTorrentServiceServer
|
pb.UnimplementedTorrentServiceServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTorrentServer(cfg config.Config) (*TorrentServer, error) {
|
func NewTorrentServer(client TorrentClient) *TorrentServer {
|
||||||
service, err := NewTorrentService(cfg)
|
return &TorrentServer{service: NewTorrentService(client)}
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to initialize TorrentService")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TorrentServer{service: service}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *TorrentServer) List(ctx context.Context, req *pb.ListRequest) (*pb.ListResponse, error) {
|
func (server *TorrentServer) List(ctx context.Context, req *pb.ListRequest) (*pb.ListResponse, error) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
|
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
|
||||||
"homelab.lan/music-agregator/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TorrentService struct {
|
type TorrentService struct {
|
||||||
@@ -19,27 +18,10 @@ type TorrentService struct {
|
|||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTorrentService(cfg config.Config) (*TorrentService, error) {
|
func NewTorrentService(client TorrentClient) *TorrentService {
|
||||||
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")
|
|
||||||
|
|
||||||
return &TorrentService{
|
return &TorrentService{
|
||||||
client: client,
|
client: client,
|
||||||
token: token,
|
}
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *TorrentService) List(req *pb.ListRequest) (*pb.ListResponse, error) {
|
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")
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package tracker
|
|
||||||
|
|
||||||
type ParserFactory interface {
|
|
||||||
GetParser(categories []string) Parser
|
|
||||||
}
|
|
||||||
@@ -196,6 +196,7 @@ func (p *GenericParser) fillFromTorrent(r *release.Release, torrentData []byte)
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.fillFromTitle(r, info.Name)
|
p.fillFromTitle(r, info.Name)
|
||||||
|
p.deduceFromFileSize(r)
|
||||||
|
|
||||||
log.Trace().
|
log.Trace().
|
||||||
Str("hash", r.InfoHash).
|
Str("hash", r.InfoHash).
|
||||||
@@ -205,6 +206,9 @@ func (p *GenericParser) fillFromTorrent(r *release.Release, torrentData []byte)
|
|||||||
Bool("cover", r.HasCoverArt).
|
Bool("cover", r.HasCoverArt).
|
||||||
Bool("cue", r.HasCueSheet).
|
Bool("cue", r.HasCueSheet).
|
||||||
Bool("log", r.HasRipLog).
|
Bool("log", r.HasRipLog).
|
||||||
|
Int("bit_depth", r.BitDepth).
|
||||||
|
Int("sample_rate", r.SampleRate).
|
||||||
|
Str("bitrate", r.Bitrate).
|
||||||
Msg("filled from torrent")
|
Msg("filled from torrent")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +271,75 @@ func (p *GenericParser) fillFromTitle(r *release.Release, title string) {
|
|||||||
Msg("filled from title")
|
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.\-]+`)
|
var trackNumberPrefix = regexp.MustCompile(`^\d{1,3}[\s.\-]+`)
|
||||||
|
|
||||||
func cleanTrackName(name string) string {
|
func cleanTrackName(name string) string {
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
package tracker
|
package tracker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
||||||
"homelab.lan/music-agregator/internal/release"
|
"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) {
|
func TestGenericParser_Parse(t *testing.T) {
|
||||||
p := NewGenericParser()
|
p := NewGenericParser()
|
||||||
|
|
||||||
@@ -95,77 +121,452 @@ func TestGenericParser_Parse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenericParser_ParseTorrent(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()
|
p := NewGenericParser()
|
||||||
r := p.ParseTorrent(torrentData, album)
|
|
||||||
|
|
||||||
t.Logf("Artist: %s", r.Artist)
|
makeFlacFiles := func(count int, sizeMB float64) []testFile {
|
||||||
t.Logf("Album: %s", r.Album)
|
files := make([]testFile, count)
|
||||||
t.Logf("Year: %d", r.Year)
|
for i := range files {
|
||||||
t.Logf("Type: %s", r.Type)
|
files[i] = testFile{
|
||||||
t.Logf("Genres: %v", r.Genres)
|
path: fmt.Sprintf("%02d - Track %d.flac", i+1, i+1),
|
||||||
t.Logf("Format: %s", r.Format)
|
size: int64(sizeMB * 1024 * 1024),
|
||||||
t.Logf("Source: %s", r.Source)
|
}
|
||||||
t.Logf("Label: %s", r.Label)
|
}
|
||||||
t.Logf("InfoHash: %s", r.InfoHash)
|
return files
|
||||||
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)
|
|
||||||
|
|
||||||
if r.Artist != "Metallica" {
|
makeMp3Files := func(count int, sizeMB float64) []testFile {
|
||||||
t.Errorf("Artist = %q, want Metallica", r.Artist)
|
files := make([]testFile, count)
|
||||||
}
|
for i := range files {
|
||||||
if r.Album != "72 Seasons" {
|
files[i] = testFile{
|
||||||
t.Errorf("Album = %q, want 72 Seasons", r.Album)
|
path: fmt.Sprintf("%02d - Track %d.mp3", i+1, i+1),
|
||||||
}
|
size: int64(sizeMB * 1024 * 1024),
|
||||||
if r.Year != 2023 {
|
}
|
||||||
t.Errorf("Year = %d, want 2023", r.Year)
|
}
|
||||||
}
|
return files
|
||||||
if r.Format != release.FormatFLAC {
|
}
|
||||||
t.Errorf("Format = %v, want FLAC", r.Format)
|
|
||||||
}
|
tests := []struct {
|
||||||
if r.AudioFileCount != 12 {
|
name string
|
||||||
t.Errorf("AudioFileCount = %d, want 12", r.AudioFileCount)
|
torrentName string
|
||||||
}
|
files []testFile
|
||||||
if !r.HasCoverArt {
|
album *metadataPb.Album
|
||||||
t.Error("expected HasCoverArt")
|
wantFormat release.AudioFormat
|
||||||
}
|
wantAudioFileCount int
|
||||||
if !r.HasCueSheet {
|
wantHasCoverArt bool
|
||||||
t.Error("expected HasCueSheet")
|
wantHasCueSheet bool
|
||||||
}
|
wantHasRipLog bool
|
||||||
if !r.HasRipLog {
|
wantSource release.Source
|
||||||
t.Error("expected HasRipLog")
|
wantInfoHashEmpty bool
|
||||||
}
|
wantBitDepth int
|
||||||
if r.Source != release.SourceCD {
|
wantSampleRate int
|
||||||
t.Errorf("Source = %v, want CD (inferred from log)", r.Source)
|
wantTrackNames []string
|
||||||
}
|
wantArtist string
|
||||||
if !r.ParsedSuccessfully {
|
wantAlbum string
|
||||||
t.Errorf("ParsedSuccessfully = false, errors: %v", r.ParseErrors)
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package tracker
|
|
||||||
|
|
||||||
import "homelab.lan/music-agregator/internal/release"
|
|
||||||
|
|
||||||
type Parser interface {
|
|
||||||
Parse(title string) *release.Release
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import "homelab.lan/music-agregator/internal/release"
|
|
||||||
|
|
||||||
type MetalParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMetalParser() *MetalParser {
|
|
||||||
return &MetalParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *MetalParser) Parse(title string) *release.Release {
|
|
||||||
r := p.NewRelease(title)
|
|
||||||
|
|
||||||
r.Genres = p.ExtractGenres(title)
|
|
||||||
if len(r.Genres) == 0 {
|
|
||||||
r.Genres = []string{"Metal"}
|
|
||||||
}
|
|
||||||
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,135 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMetalParser(t *testing.T) {
|
|
||||||
p := NewMetalParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Death metal EP",
|
|
||||||
title: "(Death Metal) Monolithic Terror - A Time To Kill (EP) - 2026, MP3, 320 kbps",
|
|
||||||
wantArtist: "Monolithic Terror",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantType: release.TypeEP,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Heavy metal album",
|
|
||||||
title: "(Heavy Metal) More - Destructor - 2026, MP3, 320 kbps",
|
|
||||||
wantArtist: "More",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Melodic death metal EP",
|
|
||||||
title: "(Melodic Death Metal) Death Brigade - Rites Of War (EP) - 2026, MP3, 320 kbps",
|
|
||||||
wantArtist: "Death Brigade",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantType: release.TypeEP,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Power metal WEB FLAC",
|
|
||||||
title: "(Heavy Metal, Power Metal) [WEB] Death Dealer - Reign of Steel - 2026, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "Death Dealer",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Thrash metal deluxe box",
|
|
||||||
title: "(Heavy/Power/Thrash Metal) Metal Church - Dead to Rights (Deluxe Box Set Edition) - 2026, MP3, 320 kbps",
|
|
||||||
wantArtist: "Metal Church",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Iron Maiden discography",
|
|
||||||
title: "(Heavy Metal, Hard Rock) Iron Maiden - Discography (146 CD + 4 WEB) - 1979-2021, AAC (tracks), VBR 320 kbps",
|
|
||||||
wantArtist: "Iron Maiden",
|
|
||||||
wantYear: 1979,
|
|
||||||
wantType: release.TypeDiscography,
|
|
||||||
wantFormat: release.FormatAAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Black metal restored",
|
|
||||||
title: "[RM] [restored] [declipped] [16/44] (Black Metal) Mayhem - 15 releases - 1987-2026, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Mayhem",
|
|
||||||
wantYear: 1987,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Black metal vinyl 24/96",
|
|
||||||
title: "(Black Metal) [LP] [24/96] Hellhammer - Apocalyptic Raids - 1984, FLAC (tracks)",
|
|
||||||
wantArtist: "Hellhammer",
|
|
||||||
wantYear: 1984,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Russian thrash vinyl rip",
|
|
||||||
title: "(Thrash Metal) КОРРОЗИЯ МЕТАЛЛА - Каннибал (VINYL RIP) - 1990, FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "КОРРОЗИЯ МЕТАЛЛА",
|
|
||||||
wantYear: 1990,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Progressive metal live",
|
|
||||||
title: "(Progressive Metal) Leprous - An Evening of Atonement (Live in Tilburg 2025) [2 CD] - 2025, MP3, 320 kbps",
|
|
||||||
wantArtist: "Leprous",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantType: release.TypeLive,
|
|
||||||
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,37 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import "homelab.lan/music-agregator/internal/release"
|
|
||||||
|
|
||||||
type MiscMusicParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMiscMusicParser() *MiscMusicParser {
|
|
||||||
return &MiscMusicParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *MiscMusicParser) 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,144 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMiscMusicParser(t *testing.T) {
|
|
||||||
p := NewMiscMusicParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Spiritual chants FLAC",
|
|
||||||
title: "(Духовные песнопения) [CD] Хор Сретенского Монастыря - Рождественские песнопения - 2005, FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "Хор Сретенского Монастыря",
|
|
||||||
wantYear: 2005,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Children songs VA",
|
|
||||||
title: "(Детские песни) [CD] VA - Любимые песни из мультфильмов - 2010, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2010,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ballroom latin VA",
|
|
||||||
title: "(Ballroom, Latin) [CD] VA - Dancelife: The Best Of Latin Music - 2008, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2008,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Nature sounds MP3",
|
|
||||||
title: "(Nature Sounds) VA - Sounds Of Nature: Rainforest - 2005, MP3, 320 kbps",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2005,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Spiritual gregorian WEB",
|
|
||||||
title: "(Spiritual) [WEB] Gregorian - Masters of Chant - 2006, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "Gregorian",
|
|
||||||
wantYear: 2006,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mixed styles VA compilation",
|
|
||||||
title: "(Mixed Styles) VA - Various Artists Compilation 2024 - 2024, MP3, 320 kbps",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2024,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Children lullabies WEB",
|
|
||||||
title: "(Детские песни) [WEB] VA - Колыбельные для малышей - 2020, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2020,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Orthodox chants CD",
|
|
||||||
title: "(Духовная музыка) [CD] VA - Православные песнопения - 2012, FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2012,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ballroom cha cha cha",
|
|
||||||
title: "(Ballroom) [CD] VA - Strictly Ballroom Dancing - Cha Cha Cha - 2006, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2006,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Nature sounds relaxation",
|
|
||||||
title: "(Nature Sounds, Relaxation) VA - Ocean Waves: Calm & Relax - 2018, MP3, 256 kbps",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2018,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "256 kbps",
|
|
||||||
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,109 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import "regexp"
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Genre at start: (Rock), (Electronic, Ambient), (Jazz / Blues)
|
|
||||||
genrePattern = regexp.MustCompile(`^\s*\(([^)]+)\)\s*`)
|
|
||||||
|
|
||||||
// Label pack: Label: Name or Label - Name
|
|
||||||
labelPattern = regexp.MustCompile(`(?i)Label\s*[:\-]\s*([^\[(]+?)(?:\s*[\[(]|\s*$)`)
|
|
||||||
|
|
||||||
// Year: single or range
|
|
||||||
yearPattern = regexp.MustCompile(`\b((?:19|20)\d{2})\b`)
|
|
||||||
yearRangePattern = regexp.MustCompile(`\b((?:19|20)\d{2})\s*[-–]\s*((?:19|20)\d{2})\b`)
|
|
||||||
|
|
||||||
// Reissue year format: 1960/2026 (original/reissue) → capture first
|
|
||||||
reissueYearPattern = regexp.MustCompile(`\b((?:19|20)\d{2})/((?:19|20)\d{2})\b`)
|
|
||||||
|
|
||||||
// Release year after dash: " - YEAR" or " - YEAR," or " - YEAR ("
|
|
||||||
releaseYearPattern = regexp.MustCompile(`\s[-–]\s*((?:19|20)\d{2})(?:[,\s(]|$)`)
|
|
||||||
|
|
||||||
// Release count: (15 CD), (30 albums), 10 releases, (50 релизов), 13 CD
|
|
||||||
releaseCountPattern = regexp.MustCompile(`(?i)(?:\()?(\d+)\s*(?:CD|albums?|releases?|релиз(?:а|ов)?|альбом(?:а|ов)?)(?:\))?`)
|
|
||||||
|
|
||||||
// Audio formats
|
|
||||||
formatPattern = regexp.MustCompile(`(?i)\b(FLAC|APE|MP3|AAC|OGG|WV|WavPack|ALAC|WAV|DSD\d*|DST\d*)\b`)
|
|
||||||
|
|
||||||
// Bitrate: 320 kbps, V0, VBR 192-320 kbps, lossless
|
|
||||||
bitratePattern = regexp.MustCompile(`(?i)(?:(\d{2,3})\s*kbps|V([012])|VBR\s*(?:~?(\d+)|(\d+)-(\d+))\s*kbps|lossless)`)
|
|
||||||
|
|
||||||
// Rip type: image+.cue, tracks+.cue, tracks
|
|
||||||
ripTypePattern = regexp.MustCompile(`(?i)(image\+\.?cue|tracks?\+\.?cue|tracks?)`)
|
|
||||||
|
|
||||||
// Hi-Res bit depth / sample rate: [24/96], [24/192], [24bit-48kHz]
|
|
||||||
hiResPattern = regexp.MustCompile(`\[(\d+)(?:/|bit[/-])(\d+(?:\.\d+)?)\s*(?:kHz)?\]`)
|
|
||||||
|
|
||||||
// DSD formats: DSD64, DSD128, DST64
|
|
||||||
dsdPattern = regexp.MustCompile(`(?i)\b(DSD|DST)(64|128|256|512)\b`)
|
|
||||||
|
|
||||||
// Source tags: [CD], [WEB], [LP], [Vinyl], [SACD], [DVDA]
|
|
||||||
sourceTagPattern = regexp.MustCompile(`(?i)\[(CD|WEB|LP|Vinyl|SACD|DVDA|HDAD|MINI-LP|EP|12"|10"|7")\]`)
|
|
||||||
|
|
||||||
// Vinyl condition: [NM], [EX], [VG+], [VG], [G], [Mint], [SS]
|
|
||||||
vinylConditionPattern = regexp.MustCompile(`\[(Mint|SS|NM|EX|VG\+?|G|F/?P)\]`)
|
|
||||||
|
|
||||||
// Special tags: [AI], [WEB], [TR24], [OF], [RM], [restored], [declipped]
|
|
||||||
specialTagPattern = regexp.MustCompile(`\[(AI|WEB|TR24|OF|RM|restored|declipped)\]`)
|
|
||||||
|
|
||||||
// Discography keywords (Russian + English)
|
|
||||||
discographyPattern = regexp.MustCompile(`(?i)\b([Дд]искографи[яи]|[Dd]iscograph(?:y|ies))\b`)
|
|
||||||
|
|
||||||
// Collection keywords
|
|
||||||
collectionPattern = regexp.MustCompile(`(?i)\b([Кк]оллекци[яи]|[Cc]ollection|[Cc]omplete\s+(?:[Ss]tudio\s+)?[Rr]ecordings?)\b`)
|
|
||||||
|
|
||||||
// Compilation keywords
|
|
||||||
compilationPattern = regexp.MustCompile(`(?i)\b([Сс]борник|[Cc]ompilation|[Vv]arious\s*[Aa]rtists?|VA)\b`)
|
|
||||||
|
|
||||||
// Anthology keywords
|
|
||||||
anthologyPattern = regexp.MustCompile(`(?i)\b([Аа]нтологи[яи]|[Aa]nthology)\b`)
|
|
||||||
|
|
||||||
// Best of / Greatest hits keywords
|
|
||||||
bestOfPattern = regexp.MustCompile(`(?i)\b([Ии]збранное|[Лл]учшее|[Bb]est\s*[Oo]f|[Gg]reatest\s*[Hh]its)\b`)
|
|
||||||
|
|
||||||
// Live / Concert keywords including venue patterns
|
|
||||||
livePattern = regexp.MustCompile(`(?i)(\b[Жж]ивой\b|\b[Кк]онцерт\b|\b[Ll]ive\b|\b[Cc]oncert\b|[Ll]ive\s*[Aa]t|[Aa]t\s+[Tt]he\s+\w+)`)
|
|
||||||
|
|
||||||
// Bootleg keywords
|
|
||||||
bootlegPattern = regexp.MustCompile(`(?i)\b([Бб]утлеги?|[Bb]ootlegs?|[Uu]nofficial)\b`)
|
|
||||||
|
|
||||||
// Soundtrack keywords
|
|
||||||
soundtrackPattern = regexp.MustCompile(`(?i)\b(OST|[Ss]oundtrack|[Сс]аундтрек|[Ss]core|[Мм]узыка\s*(?:к|из)\s*фильм[ау])\b`)
|
|
||||||
|
|
||||||
// Remaster keywords
|
|
||||||
remasterPattern = regexp.MustCompile(`(?i)\b([Рр]емастер|[Rr]emaster(?:ed)?|[Пп]ереиздани[ея]|[Rr]e-?issue)\b`)
|
|
||||||
|
|
||||||
// EP keywords
|
|
||||||
epPattern = regexp.MustCompile(`(?i)\b(EP|[Мм]ини[-\s]?[Аа]льбом|[Ee]xtended\s*[Pp]lay)\b`)
|
|
||||||
|
|
||||||
// Single keywords
|
|
||||||
singlePattern = regexp.MustCompile(`(?i)\b([Сс]ингл|[Ss]ingle)\b`)
|
|
||||||
|
|
||||||
// Standard title format: Artist - Album - Year or (Genre) Artist - Album - Year
|
|
||||||
// Captures: artist, album, year
|
|
||||||
standardTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-–]+?)\s*[-–]\s*(.+?)\s*[-–]\s*((?:19|20)\d{2})`)
|
|
||||||
|
|
||||||
// Alternative: Artist - Album (Year)
|
|
||||||
altTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-–]+?)\s*[-–]\s*(.+?)\s*\(((?:19|20)\d{2})\)`)
|
|
||||||
|
|
||||||
// Discography title: Artist - Дискография (15 CD) [1990-2020, ...]
|
|
||||||
discographyTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-–]+?)\s*[-–]\s*(?:[Дд]искографи[яи]|[Dd]iscograph(?:y|ies))`)
|
|
||||||
|
|
||||||
// Collection title: Artist - Коллекция (50 CD) [1980-2019, ...]
|
|
||||||
collectionTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-–]+?)\s*[-–]\s*(?:[Кк]оллекци[яи]|[Cc]ollection)`)
|
|
||||||
|
|
||||||
// Label pack title: (Genre) Label: Label Name (releases)
|
|
||||||
labelPackTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?i)Label:\s*([^(]+)`)
|
|
||||||
|
|
||||||
// Catalog number in brackets: [CAT001], [LABEL-001]
|
|
||||||
catalogNumPattern = regexp.MustCompile(`\[([A-Z]{2,}[-\s]?\d+[A-Z]*)\]`)
|
|
||||||
|
|
||||||
// Tags in brackets at start to strip: [RM], [restored], etc. (before or after genre)
|
|
||||||
leadingTagsPattern = regexp.MustCompile(`^(\s*\[[^\]]+\]\s*)+`)
|
|
||||||
|
|
||||||
// Tags before genre pattern: [RM] [restored] (Genre)
|
|
||||||
tagsBeforeGenrePattern = regexp.MustCompile(`^(\s*\[[^\]]+\]\s*)+\([^)]+\)\s*`)
|
|
||||||
|
|
||||||
// Clean trailing technical info: , FLAC (image+.cue)
|
|
||||||
trailingTechPattern = regexp.MustCompile(`,?\s*(?:FLAC|APE|MP3|AAC|OGG|WV|WavPack|ALAC|WAV).*$`)
|
|
||||||
)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import "homelab.lan/music-agregator/internal/release"
|
|
||||||
|
|
||||||
type PopParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPopParser() *PopParser {
|
|
||||||
return &PopParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PopParser) Parse(title string) *release.Release {
|
|
||||||
r := p.NewRelease(title)
|
|
||||||
|
|
||||||
r.Genres = p.ExtractGenres(title)
|
|
||||||
if len(r.Genres) == 0 {
|
|
||||||
r.Genres = []string{"Pop"}
|
|
||||||
}
|
|
||||||
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,135 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPopParser(t *testing.T) {
|
|
||||||
p := NewPopParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Pop CD album",
|
|
||||||
title: "(Pop) [CD] Nessa Barrett - AFTERCARE DELUXE - 2025, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Nessa Barrett",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop alternative deluxe",
|
|
||||||
title: "(Pop) (Alternative) [CD] Melanie Martinez - Cry Baby (Deluxe Edition) - 2015, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Melanie Martinez",
|
|
||||||
wantYear: 2015,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop album",
|
|
||||||
title: "(Pop) [CD] Ava Max - Diamonds & Dancefloors - 2023, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Ava Max",
|
|
||||||
wantYear: 2023,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop VA compilation",
|
|
||||||
title: "(Pop) [WEB] VA - Музыка Победы - 2025, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop limited edition",
|
|
||||||
title: "(Pop) [CD] Стрелки - Gold [Limited Edition] Maschina Records - 2026, FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "Стрелки",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop Europop",
|
|
||||||
title: "(Pop, Europop) [CD] Pupo - Insieme (2025), FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "Pupo",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop VA multi-CD",
|
|
||||||
title: "(Pop) [CD] VA - The Best Of 1980-1990 Vol. II [3 CD] - 1990, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 1990,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop dont click play",
|
|
||||||
title: "(Pop) [CD] Ava Max - Don't Click Play - 2025, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Ava Max",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop no good",
|
|
||||||
title: "(Pop) [CD] Ivy Levan - No Good - 2015, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Ivy Levan",
|
|
||||||
wantYear: 2015,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop vocal soul",
|
|
||||||
title: "(Pop, Vocal, Soul, R&B) [WEB] Hush Dusty - Love is a Battlefield Tonight - 2026, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "Hush Dusty",
|
|
||||||
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 RadioshowParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRadioshowParser() *RadioshowParser {
|
|
||||||
return &RadioshowParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *RadioshowParser) 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,143 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRadioshowParser(t *testing.T) {
|
|
||||||
p := NewRadioshowParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "BBC Essential Mix AAC",
|
|
||||||
title: "(House, Progressive House, Tech House, Dance, Electro, DnB) BBC Radio One - Essential Mix 2026, AAC (tracks), 320 kbps",
|
|
||||||
wantArtist: "BBC Radio One",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatAAC,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Russian mega mix MP3",
|
|
||||||
title: "(Club House, Progressive House, Russian Pop) Alex Kerdivar - Russian Mega Mix 21 (26.04.2026), MP3, 320 kbps",
|
|
||||||
wantArtist: "Alex Kerdivar",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Drum and bass fussy listener",
|
|
||||||
title: "(Intelligent Drum & Bass) LTJ Bukem - Fussy Listener Mix Vol 3 - 11.02.2026, MP3, 192 kbps",
|
|
||||||
wantArtist: "LTJ Bukem",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "192 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Neurofunk BBC Radio",
|
|
||||||
title: "(Neurofunk) Enta - Production Showcase Mix (BBC Radio 1) - 17.11.2024, MP3, 320 kbps",
|
|
||||||
wantArtist: "Enta",
|
|
||||||
wantYear: 2024,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Dark techstep methlab radio",
|
|
||||||
title: "(Drum & Bass, Dark Techstep) Allied - MethLab Radio Guest Mix [MLR040] - 05.11.2015, MP3, 320 kbps",
|
|
||||||
wantArtist: "Allied",
|
|
||||||
wantYear: 2015,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Goldie 6 mix VBR",
|
|
||||||
title: "(Drum & Bass) Goldie - The 6 Mix (BBC Radio 6) - 06-06-2025, MP3, V0",
|
|
||||||
wantArtist: "Goldie",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "V0",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Daphni Essential Mix",
|
|
||||||
title: "(House, Tech House) Daphni - BBC Radio 1s Essential Mix - 17-01-2026, MP3, V0",
|
|
||||||
wantArtist: "Daphni",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "V0",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Andy C 6 mix",
|
|
||||||
title: "(Drum & Bass) Andy C - The 6 Mix (BBC Radio 6) 16-01-2026, MP3, V0",
|
|
||||||
wantArtist: "Andy C",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "V0",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Club house Russian rap mix",
|
|
||||||
title: "(Club House, Russian Rap, Rap, Hip-Hop) Alex Kerdivar - Special Mega Mix 14 (17.01.2026), MP3, 320 kbps",
|
|
||||||
wantArtist: "Alex Kerdivar",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Jungle phonica mix series",
|
|
||||||
title: "(Drum & Bass, Jungle) Tim Reaper - Phonica Mix Series 128 (DJ Mix) - 2025, MP3, 320 kbps",
|
|
||||||
wantArtist: "Tim Reaper",
|
|
||||||
wantYear: 2025,
|
|
||||||
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,38 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import "homelab.lan/music-agregator/internal/release"
|
|
||||||
|
|
||||||
type ReggaeParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReggaeParser() *ReggaeParser {
|
|
||||||
return &ReggaeParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReggaeParser) Parse(title string) *release.Release {
|
|
||||||
r := p.NewRelease(title)
|
|
||||||
|
|
||||||
r.Genres = p.ExtractGenres(title)
|
|
||||||
if len(r.Genres) == 0 {
|
|
||||||
r.Genres = []string{"Reggae"}
|
|
||||||
}
|
|
||||||
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,134 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReggaeParser(t *testing.T) {
|
|
||||||
p := NewReggaeParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Reggae funk soul album",
|
|
||||||
title: "(Reggae, Funk / Soul) [CD] Diana King - Think Like A Girl (CD Album, Enhanced) - 1997, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Diana King",
|
|
||||||
wantYear: 1997,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae dawn penn",
|
|
||||||
title: "(Reggae) [CD] Dawn Penn - Come Again [1996] - 1996, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Dawn Penn",
|
|
||||||
wantYear: 1996,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae ska Bob Marley",
|
|
||||||
title: "(Reggae, Ska) [CD] Bob Marley & The Wailers - 3 альбома - (1973-1980), FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Bob Marley & The Wailers",
|
|
||||||
wantYear: 1973,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae Bob Marley collection",
|
|
||||||
title: "(Reggae) [CD] Bob Marley & The Wailers - коллекция 1970-2012 (86 альбомов), FLAC (image+.cue, tracks+.cue), lossless",
|
|
||||||
wantArtist: "Bob Marley & The Wailers",
|
|
||||||
wantYear: 1970,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae rock ska",
|
|
||||||
title: "(Reggae Rock, Ska) [CD] The English Beat - Special Beat Service - 1986, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "The English Beat",
|
|
||||||
wantYear: 1986,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae VA celebration",
|
|
||||||
title: "(Reggae, Reggae-Pop, Ragga, Euro-House) [CD] VA - Reggae Celebration '97 Vol. 1 - 1997, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 1997,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae big youth",
|
|
||||||
title: "(Reggae) [CD] Big Youth - Natty Universal Dread 1973-1979 - 2000, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Big Youth",
|
|
||||||
wantYear: 2000,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae barrington levy multi-CD",
|
|
||||||
title: "(Reggae) [CD] Barrington Levy - Sweet Reggae Music 1979-84 (2 CD) - 2012, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Barrington Levy",
|
|
||||||
wantYear: 2012,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sega-reggae elijah",
|
|
||||||
title: "(Sega-Reggae) [CD] ELIJAH - Luveologist - 2006, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "ELIJAH",
|
|
||||||
wantYear: 2006,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reggae UB40 ultimate edition",
|
|
||||||
title: "(Reggae) [WEB] UB40 - UB45 [Ultimate Edition] - 2024, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "UB40",
|
|
||||||
wantYear: 2024,
|
|
||||||
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 RockParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRockParser() *RockParser {
|
|
||||||
return &RockParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *RockParser) Parse(title string) *release.Release {
|
|
||||||
r := p.NewRelease(title)
|
|
||||||
|
|
||||||
r.Genres = p.ExtractGenres(title)
|
|
||||||
if len(r.Genres) == 0 {
|
|
||||||
r.Genres = []string{"Rock"}
|
|
||||||
}
|
|
||||||
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 TestRockParser(t *testing.T) {
|
|
||||||
p := NewRockParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Blues rock single",
|
|
||||||
title: "(Rock, Blues Rock) [WEB] The Rolling Stones - In The Stars [Single] - 2026, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "The Rolling Stones",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeSingle,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Hard rock single",
|
|
||||||
title: "(Hard Rock) [WEB] J.R. Blackmore - Moments Of Magic (Single) - 2012, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "J.R. Blackmore",
|
|
||||||
wantYear: 2012,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeSingle,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Psychedelic rock collection",
|
|
||||||
title: "(Psychedelic Rock, Hard Rock, Blues Rock, Progressive Rock) [CD] Atomic Rooster - Collection Albums 1970-1973 (8 CD), FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "Atomic Rooster",
|
|
||||||
wantYear: 1970,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCollection,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Rock discography",
|
|
||||||
title: "(Rock) [CD] Voice Of The Beehive - Discography - 1988-2022 (18 releases), FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Voice Of The Beehive",
|
|
||||||
wantYear: 1988,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeDiscography,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Indie rock re-release",
|
|
||||||
title: "(Indie Rock / Indie Pop) [WEB] Easy - Magic Seed - 1990, FLAC (tracks), lossless(Re-release)",
|
|
||||||
wantArtist: "Easy",
|
|
||||||
wantYear: 1990,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Progressive rock album",
|
|
||||||
title: "(Progressive Rock) [WEB] Os Mutantes - De Volta Ao Planeta Dos Mutantes - 2006, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "Os Mutantes",
|
|
||||||
wantYear: 2006,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Hard rock reissue",
|
|
||||||
title: "(Hard Rock) [CD] Gene Simmons - Gene Simmons - 1978 (1991), FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Gene Simmons",
|
|
||||||
wantYear: 1978,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop rock collection",
|
|
||||||
title: "(Pop Rock) [CD] Sneakers (with Sanne Salomonsen) - Collection (1980-1997) (4 releases), FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "Sneakers (with Sanne Salomonsen)",
|
|
||||||
wantYear: 1980,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCollection,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Southern rock album",
|
|
||||||
title: "(Southern Rock, Hard Rock, Blues Rock) [WEB] The Cold Stares - Texas - 2026, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "The Cold Stares",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Hard rock 70s style",
|
|
||||||
title: "(Hard Rock, 70's) [WEB] Lynx - Trinity of Suns - 2026, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "Lynx",
|
|
||||||
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 ShansonParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewShansonParser() *ShansonParser {
|
|
||||||
return &ShansonParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ShansonParser) Parse(title string) *release.Release {
|
|
||||||
r := p.NewRelease(title)
|
|
||||||
|
|
||||||
r.Genres = p.ExtractGenres(title)
|
|
||||||
if len(r.Genres) == 0 {
|
|
||||||
r.Genres = []string{"Shanson"}
|
|
||||||
}
|
|
||||||
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,136 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestShansonParser(t *testing.T) {
|
|
||||||
p := NewShansonParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "French shanson VA multi-CD",
|
|
||||||
title: "(Shanson) [4 CD] VA - Chansons Francaises - 2011, FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2011,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Retro shanson album",
|
|
||||||
title: "(Retro-Shanson) [CD] Биртман - Следы от компота - 2015, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Биртман",
|
|
||||||
wantYear: 2015,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop shanson collection",
|
|
||||||
title: "(Pop, Shanson) Sylvie Vartan - Best Artist Collection - 198?, APE (tracks+.cue) lossless",
|
|
||||||
wantArtist: "Sylvie Vartan",
|
|
||||||
wantFormat: release.FormatAPE,
|
|
||||||
wantType: release.TypeCollection,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "French shanson Piaf",
|
|
||||||
title: "(French Shanson) Jil Aigrot - Words Of Love: The Voice of Edith Piaf in the Award-winning Film La Vie En Rose - 2008, APE (image+.cue), lossless",
|
|
||||||
wantArtist: "Jil Aigrot",
|
|
||||||
wantYear: 2008,
|
|
||||||
wantFormat: release.FormatAPE,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Pop shanson Joe Dassin best of",
|
|
||||||
title: "(Pop/Shanson) Joe Dassin - Greatest Hits (2 CDs SET DIGIPACK) - 2007, FLAC (image + .cue), lossless",
|
|
||||||
wantArtist: "Joe Dassin",
|
|
||||||
wantYear: 2007,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Shanson Adamo WavPack",
|
|
||||||
title: "(Shanson) Salvatore Adamo - L'essentiel - 2003, WAVPack (image+.cue), lossless",
|
|
||||||
wantArtist: "Salvatore Adamo",
|
|
||||||
wantYear: 2003,
|
|
||||||
wantFormat: release.FormatWavPack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lounge shanson french pop",
|
|
||||||
title: "(Lounge, Shanson, French-Pop) Helena Noguerra - Nee Dans La Nature - 2004, APE (image + .cue), lossless",
|
|
||||||
wantArtist: "Helena Noguerra",
|
|
||||||
wantYear: 2004,
|
|
||||||
wantFormat: release.FormatAPE,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Shanson VA blatnaya",
|
|
||||||
title: "(Shanson) [CD] VA - Блатная Империя 100 лучших Хитов - 2007, FLAC (tracks), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2007,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeCompilation,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Bard song collection",
|
|
||||||
title: "(Авторская песня) [CD] Булат Окуджава - Коллекция (20 CD) - 1967-2001, FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "Булат Окуджава",
|
|
||||||
wantYear: 1967,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Russian shanson discography MP3",
|
|
||||||
title: "(Шансон) Михаил Круг - Дискография (34 альбома) - 1994-2009, MP3, 192-320 kbps",
|
|
||||||
wantArtist: "Михаил Круг",
|
|
||||||
wantYear: 1994,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
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,34 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import "homelab.lan/music-agregator/internal/release"
|
|
||||||
|
|
||||||
type SoundtracksParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSoundtracksParser() *SoundtracksParser {
|
|
||||||
return &SoundtracksParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SoundtracksParser) Parse(title string) *release.Release {
|
|
||||||
r := p.NewRelease(title)
|
|
||||||
|
|
||||||
r.Genres = p.ExtractGenres(title)
|
|
||||||
r.Type = release.TypeSoundtrack
|
|
||||||
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.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,139 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSoundtracksParser(t *testing.T) {
|
|
||||||
p := NewSoundtracksParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantAlbum string
|
|
||||||
wantYear int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantType release.Type
|
|
||||||
wantBitrate string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Game score MP3",
|
|
||||||
title: "(Score) Yoann Laulan - Cinderia (Original Game Soundtrack) - 2026, MP3, 320 kbps",
|
|
||||||
wantArtist: "Yoann Laulan",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatMP3,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantBitrate: "320 kbps",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Synthwave game soundtrack",
|
|
||||||
title: "(Synthwave, Dark Synth, Retrowave) VA - Tackle for Loss Official Videogame Soundtrack - 2026, MP3, 320 kbps",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Yakuza game OST collection",
|
|
||||||
title: "(Score / Soundtrack) Yakuza Original Soundtracks (39 albums) (SEGA, VA) - 2007-2026, MP3 (tracks), 320 kbps",
|
|
||||||
wantYear: 2007,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Film score CD FLAC",
|
|
||||||
title: "(Score) [CD] Jonny Greenwood - One Battle After Another (Original Motion Picture Soundtrack) - 2025, FLAC (image+.cue), lossless",
|
|
||||||
wantArtist: "Jonny Greenwood",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "One Piece collection",
|
|
||||||
title: "(Score) VA - One Piece Soundtrack Collection (4 releases) - 2023-2026, MP3 (tracks), 320 kbps",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2023,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Life is Strange OST collection",
|
|
||||||
title: "(Score/Soundtrack/OST) Jonathan Morali - Life is Strange Collection (8 CD) - 2016-2026, MP3, 320 kbps",
|
|
||||||
wantArtist: "Jonathan Morali",
|
|
||||||
wantYear: 2016,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "TR24 game soundtrack WEB",
|
|
||||||
title: "[TR24][OF][GM] N.J. Apostol - Routine Original Soundtrack - 2026 (Score), FLAC (tracks), lossless",
|
|
||||||
wantArtist: "N.J. Apostol",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Hans Zimmer F1 film",
|
|
||||||
title: "(Score, Soundtrack) [CD] Hans Zimmer - F1 The Movie - 2025, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "Hans Zimmer",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Stranger Things Netflix",
|
|
||||||
title: "(Soundtrack) [CD] VA - Stranger Things Soundtrack from the Netflix Series Season 5 - 2026, FLAC (tracks+.cue), lossless",
|
|
||||||
wantArtist: "VA",
|
|
||||||
wantYear: 2026,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Last of Us HBO TR24",
|
|
||||||
title: "[TR24][OF][TV] Gustavo Santaolalla - The Last of Us Soundtrack from HBO Original Series - 2023 (Score/Soundtrack)",
|
|
||||||
wantArtist: "Gustavo Santaolalla",
|
|
||||||
wantYear: 2023,
|
|
||||||
wantType: release.TypeSoundtrack,
|
|
||||||
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 r.Type != release.TypeSoundtrack {
|
|
||||||
t.Errorf("Type = %v, want Soundtrack", r.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
|
|
||||||
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import "homelab.lan/music-agregator/internal/release"
|
|
||||||
|
|
||||||
type VinylDigitizationParser struct {
|
|
||||||
BaseParser
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewVinylDigitizationParser() *VinylDigitizationParser {
|
|
||||||
return &VinylDigitizationParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *VinylDigitizationParser) 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 = release.SourceVinyl
|
|
||||||
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 condMatch := vinylConditionPattern.FindStringSubmatch(title); len(condMatch) >= 2 {
|
|
||||||
r.Tags = append(r.Tags, "Vinyl:"+condMatch[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Artist == "" {
|
|
||||||
p.AddError(r, "failed to extract artist")
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"homelab.lan/music-agregator/internal/release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestVinylDigitizationParser(t *testing.T) {
|
|
||||||
p := NewVinylDigitizationParser()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantArtist string
|
|
||||||
wantYear int
|
|
||||||
wantBitDepth int
|
|
||||||
wantSampleRate int
|
|
||||||
wantFormat release.AudioFormat
|
|
||||||
wantRipType string
|
|
||||||
wantParseOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "standard LP 24/192",
|
|
||||||
title: "(Pop-Rock/Punk) [LP] [24/192] Сектор Газа - Ядрена вошь - 1990, WavPack (image+.cue)",
|
|
||||||
wantArtist: "Сектор Газа",
|
|
||||||
wantYear: 1990,
|
|
||||||
wantBitDepth: 24,
|
|
||||||
wantSampleRate: 192000,
|
|
||||||
wantFormat: release.FormatWavPack,
|
|
||||||
wantRipType: "image+.cue",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "massive vinyl collection",
|
|
||||||
title: "(Synth-Pop) [LP/12''/10''/7''] [24/96] Depeche Mode - The Vinyl Collection (17 Albums, 66 Singles, 6 Compilations, 51 Bootlegs) (429 Releases) - 1981-2024, FLAC (tracks) lossless",
|
|
||||||
wantArtist: "Depeche Mode",
|
|
||||||
wantYear: 1981,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "2xLP 32bit",
|
|
||||||
title: "(Soft Rock, Pop Rock) [2xLP] [32/176.4] Genesis - Turn It On Again - The Hits - 1999(2024,Reissue, 25th anniversary.), WavPack (tracks)",
|
|
||||||
wantArtist: "Genesis",
|
|
||||||
wantYear: 1999,
|
|
||||||
wantBitDepth: 32,
|
|
||||||
wantSampleRate: 176400,
|
|
||||||
wantFormat: release.FormatWavPack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "32/384 ultra high res",
|
|
||||||
title: "(Prog Rock) [LP] [32/384] Emerson, Lake & Palmer-Emerson, Lake & Palmer - 2025 (1970), WavPack (tracks)",
|
|
||||||
wantYear: 2025,
|
|
||||||
wantBitDepth: 32,
|
|
||||||
wantSampleRate: 384000,
|
|
||||||
wantFormat: release.FormatWavPack,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "soul LP",
|
|
||||||
title: "(Soul, Funk) [LP] [24/192] Curtis Mayfield - Curtis - 1970/2025, FLAC (tracks)",
|
|
||||||
wantArtist: "Curtis Mayfield",
|
|
||||||
wantYear: 1970,
|
|
||||||
wantBitDepth: 24,
|
|
||||||
wantSampleRate: 192000,
|
|
||||||
wantFormat: release.FormatFLAC,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "16/44 standard",
|
|
||||||
title: "(Rock) [LP] [16/44] Tony Sheridan - Collection 4LP - 1976-1987, FLAC (image+.cue)",
|
|
||||||
wantArtist: "Tony Sheridan",
|
|
||||||
wantYear: 1976,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MFSL pressing",
|
|
||||||
title: "(Rock, Pop Rock) [LP] [24/96] Fleetwood Mac – Mirage - 1982 (1984 MFSL 1-119), FLAC (tracks)",
|
|
||||||
wantArtist: "Fleetwood Mac",
|
|
||||||
wantYear: 1982,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple LPs in one",
|
|
||||||
title: "(Rock) [LP] [24/96] 10cc - 2LP's - 1976, 1977, FLAC (tracks+.cue)",
|
|
||||||
wantArtist: "10cc",
|
|
||||||
wantYear: 1976,
|
|
||||||
wantRipType: "tracks+.cue",
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "collection from vinyl",
|
|
||||||
title: "(Progressive Rock) [LP] [24/192] Marillion, Fish - Vinyl Collection - 1982-1994 (6 releases), FLAC (image+.cue)",
|
|
||||||
wantArtist: "Marillion, Fish",
|
|
||||||
wantYear: 1982,
|
|
||||||
wantParseOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Japan vinyl",
|
|
||||||
title: "(Pop) [LP] [24/96] ABBA - The Album (Original Japan Vinyl) - 1977, FLAC (tracks)",
|
|
||||||
wantArtist: "ABBA",
|
|
||||||
wantYear: 1977,
|
|
||||||
wantBitDepth: 24,
|
|
||||||
wantSampleRate: 96000,
|
|
||||||
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.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
|
|
||||||
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
|
|
||||||
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Source != release.SourceVinyl {
|
|
||||||
t.Errorf("Source = %v, want Vinyl", r.Source)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,19 +2,13 @@ package workers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/riverqueue/river"
|
"github.com/riverqueue/river"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"homelab.lan/music-agregator/internal/analysis"
|
||||||
"homelab.lan/music-agregator/internal/database"
|
"homelab.lan/music-agregator/internal/database"
|
||||||
"homelab.lan/music-agregator/internal/torrent"
|
"homelab.lan/music-agregator/internal/torrent"
|
||||||
)
|
)
|
||||||
@@ -32,7 +26,11 @@ type PollDownloadWorker struct {
|
|||||||
TorrentClient torrent.TorrentClient
|
TorrentClient torrent.TorrentClient
|
||||||
Downloads *database.DownloadRepository
|
Downloads *database.DownloadRepository
|
||||||
DownloadFiles *database.DownloadFileRepository
|
DownloadFiles *database.DownloadFileRepository
|
||||||
|
AlbumReleases *database.AlbumReleaseRepository
|
||||||
|
TrackReleases *database.TrackReleaseRepository
|
||||||
RiverClient *river.Client[pgx.Tx]
|
RiverClient *river.Client[pgx.Tx]
|
||||||
|
PathMapper *torrent.PathMapper
|
||||||
|
Analyzer *analysis.ReleaseAnalyzer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownloadArgs]) error {
|
func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownloadArgs]) error {
|
||||||
@@ -77,14 +75,24 @@ func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownlo
|
|||||||
func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadArgs, t torrent.TorrentInfo) error {
|
func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadArgs, t torrent.TorrentInfo) error {
|
||||||
log.Info().Str("hash", args.TorrentHash).Str("path", t.ContentPath).Msg("download completed")
|
log.Info().Str("hash", args.TorrentHash).Str("path", t.ContentPath).Msg("download completed")
|
||||||
|
|
||||||
if err := w.Downloads.SetCompleted(ctx, args.DownloadID, t.SavePath); err != nil {
|
contentPath := t.ContentPath
|
||||||
|
if w.PathMapper != nil {
|
||||||
|
contentPath = w.PathMapper.ToHost(contentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
savePath := t.SavePath
|
||||||
|
if w.PathMapper != nil {
|
||||||
|
savePath = w.PathMapper.ToHost(savePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Downloads.SetCompleted(ctx, args.DownloadID, savePath); err != nil {
|
||||||
log.Error().Err(err).Msg("failed to update download as completed")
|
log.Error().Err(err).Msg("failed to update download as completed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := scanAndHashFiles(t.ContentPath)
|
files, err := analysis.ScanAndHashFiles(contentPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("path", t.ContentPath).Msg("failed to scan downloaded files")
|
log.Error().Err(err).Str("path", contentPath).Msg("failed to scan downloaded files")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +110,13 @@ func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadA
|
|||||||
Int("files", len(files)).
|
Int("files", len(files)).
|
||||||
Msg("download files scanned and hashed")
|
Msg("download files scanned and hashed")
|
||||||
|
|
||||||
|
if w.Analyzer != nil {
|
||||||
|
_, _, err := w.Analyzer.AnalyzeAndPersist(ctx, args.DownloadID, contentPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to analyze release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,63 +163,3 @@ func (w *PollDownloadWorker) RecoverOrphanedDownloads(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var audioExtensions = map[string]bool{
|
|
||||||
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
|
|
||||||
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 audioExtensions[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
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user