Add album/track releases with audio analysis, AnalyzeAlbumRelease RPC, Docker path auto-resolution, release parsing decision tree
This commit is contained in:
@@ -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, torrentClient torrent.TorrentClient) *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),
|
||||||
|
AlbumReleases: database.NewAlbumReleaseRepository(db.Pool),
|
||||||
|
TrackReleases: database.NewTrackReleaseRepository(db.Pool),
|
||||||
TorrentClient: torrentClient,
|
TorrentClient: torrentClient,
|
||||||
|
PathMapper: pathMapper,
|
||||||
|
Analyzer: analysis.NewReleaseAnalyzer(db),
|
||||||
}
|
}
|
||||||
|
|
||||||
riverWorkers := river.NewWorkers()
|
riverWorkers := river.NewWorkers()
|
||||||
@@ -161,9 +166,14 @@ func serveGrpc(config config.Config) {
|
|||||||
log.Fatal().Err(err).Msg("failed to create torrent client")
|
log.Fatal().Err(err).Msg("failed to create torrent client")
|
||||||
}
|
}
|
||||||
|
|
||||||
rs := setupRiver(ctx, config, db, torrentClient)
|
pathMapper, err := torrent.NewPathMapper(config.Torrent.ContainerName, torrentClient)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create path mapper")
|
||||||
|
}
|
||||||
|
|
||||||
musiscAgregatorSeerver, err := internal.NewMusicAgregatorServer(config, rs.client, torrentClient, db)
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -35,10 +35,11 @@ type Config struct {
|
|||||||
} `yaml:"indexer"`
|
} `yaml:"indexer"`
|
||||||
|
|
||||||
Torrent struct {
|
Torrent struct {
|
||||||
ClientType TorrentClientType `yaml:"client_type"`
|
ClientType TorrentClientType `yaml:"client_type"`
|
||||||
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,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
|
||||||
|
}
|
||||||
@@ -141,6 +141,18 @@ 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) 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
|
||||||
|
}
|
||||||
+6
-2
@@ -19,8 +19,8 @@ type MusicAgregatorServer struct {
|
|||||||
pb.UnimplementedMusicAgregatorServiceServer
|
pb.UnimplementedMusicAgregatorServiceServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, 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, torrentClient, 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
|
||||||
@@ -46,6 +46,10 @@ 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) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
|
||||||
|
return s.service.AnalyzeAlbumRelease(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MusicAgregatorServer) Register(server *grpc.Server) {
|
func (s *MusicAgregatorServer) Register(server *grpc.Server) {
|
||||||
pb.RegisterMusicAgregatorServiceServer(server, s)
|
pb.RegisterMusicAgregatorServiceServer(server, s)
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-5
@@ -15,6 +15,7 @@ import (
|
|||||||
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 +39,17 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, 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")
|
||||||
@@ -70,10 +75,14 @@ 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),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +92,7 @@ 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 {
|
||||||
return &MusicAgregatorService{
|
return &MusicAgregatorService{
|
||||||
@@ -91,10 +101,14 @@ func NewMusicAgregatorServiceWithDeps(
|
|||||||
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +218,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")
|
||||||
@@ -299,10 +322,128 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
|
|||||||
tracks = append(tracks, td)
|
tracks = append(tracks, td)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.GetAlbumResponse{
|
info := &pb.AlbumInfo{
|
||||||
Album: album,
|
Album: album,
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
albumReleases, err := service.albumReleases.GetByAlbumID(ctx, dbAlbum.ID)
|
||||||
|
if err == nil && len(albumReleases) > 0 {
|
||||||
|
ar := albumReleases[0]
|
||||||
|
releaseDetail := &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 {
|
||||||
|
releaseDetail.BitDepth = int32(*ar.BitDepth)
|
||||||
|
}
|
||||||
|
if ar.SampleRate != nil {
|
||||||
|
releaseDetail.SampleRate = int32(*ar.SampleRate)
|
||||||
|
}
|
||||||
|
if ar.Source != nil {
|
||||||
|
releaseDetail.Source = *ar.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
trackReleases, err := service.trackReleases.GetByAlbumReleaseID(ctx, ar.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, tr := range trackReleases {
|
||||||
|
trd := &pb.TrackReleaseDetail{
|
||||||
|
Id: tr.ID,
|
||||||
|
Title: tr.Title,
|
||||||
|
TrackNumber: int32(tr.TrackNumber),
|
||||||
|
DiscNumber: int32(tr.DiscNumber),
|
||||||
|
Format: tr.Format,
|
||||||
|
Channels: int32(tr.Channels),
|
||||||
|
FileSize: tr.FileSize,
|
||||||
|
FilePath: tr.FilePath,
|
||||||
|
}
|
||||||
|
if tr.TrackID != nil {
|
||||||
|
trd.TrackId = *tr.TrackID
|
||||||
|
}
|
||||||
|
if tr.DurationMs != nil {
|
||||||
|
trd.DurationMs = int32(*tr.DurationMs)
|
||||||
|
}
|
||||||
|
if tr.BitDepth != nil {
|
||||||
|
trd.BitDepth = int32(*tr.BitDepth)
|
||||||
|
}
|
||||||
|
if tr.SampleRate != nil {
|
||||||
|
trd.SampleRate = int32(*tr.SampleRate)
|
||||||
|
}
|
||||||
|
if tr.BitrateKbps != nil {
|
||||||
|
trd.BitrateKbps = int32(*tr.BitrateKbps)
|
||||||
|
}
|
||||||
|
releaseDetail.Tracks = append(releaseDetail.Tracks, trd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Release = releaseDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, 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) {
|
||||||
@@ -475,8 +616,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
|
||||||
}
|
}
|
||||||
@@ -488,7 +634,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ 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
|
||||||
|
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,28 @@ func (t *QbittorrentListItem) toTorrentInfo() TorrentInfo {
|
|||||||
Availability: t.Availability,
|
Availability: t.Availability,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ service MusicAgregatorService {
|
|||||||
rpc MonitorAlbum(MonitorAlbumRequest) returns (MonitorAlbumResponse) {}
|
rpc MonitorAlbum(MonitorAlbumRequest) returns (MonitorAlbumResponse) {}
|
||||||
rpc GetArtists(GetArtistsRequest) returns (GetArtistsResponse) {}
|
rpc GetArtists(GetArtistsRequest) returns (GetArtistsResponse) {}
|
||||||
rpc GetAlbum(GetAlbumRequest) returns (GetAlbumResponse) {}
|
rpc GetAlbum(GetAlbumRequest) returns (GetAlbumResponse) {}
|
||||||
|
rpc AnalyzeAlbumRelease(AnalyzeAlbumReleaseRequest) returns (AnalyzeAlbumReleaseResponse) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
message MonitorAlbumRequest {
|
message MonitorAlbumRequest {
|
||||||
@@ -81,9 +82,22 @@ message GetAlbumRequest {
|
|||||||
string album_id = 1;
|
string album_id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetAlbumResponse {
|
message AlbumInfo {
|
||||||
AlbumDetail album = 1;
|
AlbumDetail album = 1;
|
||||||
repeated TrackDetail tracks = 2;
|
repeated TrackDetail tracks = 2;
|
||||||
|
AlbumReleaseDetail release = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetAlbumResponse {
|
||||||
|
AlbumInfo info = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AnalyzeAlbumReleaseRequest {
|
||||||
|
string album_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AnalyzeAlbumReleaseResponse {
|
||||||
|
AlbumInfo info = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message TrackDetail {
|
message TrackDetail {
|
||||||
@@ -110,6 +124,40 @@ message TrackFile {
|
|||||||
int64 size = 3;
|
int64 size = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message AlbumReleaseDetail {
|
||||||
|
string id = 1;
|
||||||
|
string format = 2;
|
||||||
|
int32 bit_depth = 3;
|
||||||
|
int32 sample_rate = 4;
|
||||||
|
int32 channels = 5;
|
||||||
|
bool is_lossless = 6;
|
||||||
|
string source = 7;
|
||||||
|
int64 total_size = 8;
|
||||||
|
int32 total_duration_ms = 9;
|
||||||
|
int32 track_count = 10;
|
||||||
|
bool has_cover_art = 11;
|
||||||
|
bool has_cue_sheet = 12;
|
||||||
|
bool has_rip_log = 13;
|
||||||
|
string path = 14;
|
||||||
|
repeated TrackReleaseDetail tracks = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TrackReleaseDetail {
|
||||||
|
string id = 1;
|
||||||
|
string track_id = 2;
|
||||||
|
string title = 3;
|
||||||
|
int32 track_number = 4;
|
||||||
|
int32 disc_number = 5;
|
||||||
|
int32 duration_ms = 6;
|
||||||
|
string format = 7;
|
||||||
|
int32 bit_depth = 8;
|
||||||
|
int32 sample_rate = 9;
|
||||||
|
int32 channels = 10;
|
||||||
|
int32 bitrate_kbps = 11;
|
||||||
|
int64 file_size = 12;
|
||||||
|
string file_path = 13;
|
||||||
|
}
|
||||||
|
|
||||||
message MonitoredRelease {
|
message MonitoredRelease {
|
||||||
string info_hash = 1;
|
string info_hash = 1;
|
||||||
string artist = 2;
|
string artist = 2;
|
||||||
|
|||||||
@@ -81,11 +81,12 @@ func (m *mockMetadataClient) SyncArtist(ctx context.Context, in *metadataPb.Sync
|
|||||||
}
|
}
|
||||||
|
|
||||||
type mockTorrentClient struct {
|
type mockTorrentClient struct {
|
||||||
LoginFunc func(username, password string) (string, error)
|
LoginFunc func(username, password string) (string, error)
|
||||||
ListFunc func() ([]torrent.TorrentInfo, error)
|
ListFunc func() ([]torrent.TorrentInfo, error)
|
||||||
FindFunc func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error)
|
FindFunc func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error)
|
||||||
AddTorrentFunc func(file torrent.TorrentFile) error
|
AddTorrentFunc func(file torrent.TorrentFile, savePath string) error
|
||||||
AddMagnetFunc func(magnetURI string) error
|
AddMagnetFunc func(magnetURI string, savePath string) error
|
||||||
|
DefaultSavePathFunc func() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTorrentClient) Login(username, password string) (string, error) {
|
func (m *mockTorrentClient) Login(username, password string) (string, error) {
|
||||||
@@ -109,20 +110,27 @@ func (m *mockTorrentClient) Find(opts torrent.FindOptions) ([]torrent.TorrentInf
|
|||||||
return nil, fmt.Errorf("not mocked")
|
return nil, fmt.Errorf("not mocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTorrentClient) AddTorrent(file torrent.TorrentFile) error {
|
func (m *mockTorrentClient) AddTorrent(file torrent.TorrentFile, savePath string) error {
|
||||||
if m.AddTorrentFunc != nil {
|
if m.AddTorrentFunc != nil {
|
||||||
return m.AddTorrentFunc(file)
|
return m.AddTorrentFunc(file, savePath)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("not mocked")
|
return fmt.Errorf("not mocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTorrentClient) AddMagnet(magnetURI string) error {
|
func (m *mockTorrentClient) AddMagnet(magnetURI string, savePath string) error {
|
||||||
if m.AddMagnetFunc != nil {
|
if m.AddMagnetFunc != nil {
|
||||||
return m.AddMagnetFunc(magnetURI)
|
return m.AddMagnetFunc(magnetURI, savePath)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("not mocked")
|
return fmt.Errorf("not mocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTorrentClient) DefaultSavePath() (string, error) {
|
||||||
|
if m.DefaultSavePathFunc != nil {
|
||||||
|
return m.DefaultSavePathFunc()
|
||||||
|
}
|
||||||
|
return "/downloads", nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockSearcher struct {
|
type mockSearcher struct {
|
||||||
SearchFunc func(query string, limit int32, indexer string) (*indexer.SearchResponse, error)
|
SearchFunc func(query string, limit int32, indexer string) (*indexer.SearchResponse, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestMonitorAlbum_HappyPath(t *testing.T) {
|
|||||||
return []torrent.TorrentInfo{}, nil
|
return []torrent.TorrentInfo{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error {
|
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ func TestMonitorAlbum_ArtistPersistFails(t *testing.T) {
|
|||||||
return []torrent.TorrentInfo{}, nil
|
return []torrent.TorrentInfo{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error {
|
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ func TestMonitorAlbum_QBitDown(t *testing.T) {
|
|||||||
return nil, assert.AnError
|
return nil, assert.AnError
|
||||||
}
|
}
|
||||||
|
|
||||||
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error {
|
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
|
||||||
return assert.AnError
|
return assert.AnError
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +589,7 @@ func TestMonitorAlbum_AddMagnetFails(t *testing.T) {
|
|||||||
return []torrent.TorrentInfo{}, nil
|
return []torrent.TorrentInfo{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string) error {
|
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
|
||||||
return assert.AnError
|
return assert.AnError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func setupSuite(t *testing.T) *testSuite {
|
|||||||
mocks.torrent,
|
mocks.torrent,
|
||||||
mocks.magnet,
|
mocks.magnet,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
db,
|
db,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
package unit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
||||||
|
"homelab.lan/music-agregator/internal/release"
|
||||||
|
"homelab.lan/music-agregator/internal/tracker"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testFile struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTorrentData(name string, files []testFile) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteString("d8:announce35:http://tracker.example.com/announce4:infod")
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
buf.WriteString(fmt.Sprintf("6:lengthi0e4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", len(name), name))
|
||||||
|
} else if len(files) == 1 {
|
||||||
|
buf.WriteString(fmt.Sprintf("6:lengthi%de4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", files[0].size, len(files[0].path), files[0].path))
|
||||||
|
} else {
|
||||||
|
buf.WriteString("5:filesl")
|
||||||
|
for _, f := range files {
|
||||||
|
buf.WriteString(fmt.Sprintf("d6:lengthi%de4:pathl%d:%see", f.size, len(f.path), f.path))
|
||||||
|
}
|
||||||
|
buf.WriteString(fmt.Sprintf("e4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", len(name), name))
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString("ee")
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenericParser_Parse(t *testing.T) {
|
||||||
|
p := tracker.NewGenericParser()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
title string
|
||||||
|
wantBitrate string
|
||||||
|
wantBitDepth int
|
||||||
|
wantSampleRate int
|
||||||
|
wantSource release.Source
|
||||||
|
wantRipType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "discography no hires",
|
||||||
|
title: "System Of A Down - Discography [FLAC Songs] [PMEDIA]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hiphop hires 24-44",
|
||||||
|
title: "Snoop Dogg - 10 Til' Midnight (2026 Hip Hop Rap) [Flac 24-44]",
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 44000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pop hires 24bit",
|
||||||
|
title: "Sabrina Carpenter - Short n' Sweet [Deluxe] [2025] [Hi-Res FLAC 24bit]-Sc4r3cr0w",
|
||||||
|
wantBitDepth: 24,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rock hires 24bit",
|
||||||
|
title: "Linkin Park - From Zero [Deluxe Edition] [2025] [Hi-Res] [FLAC-24bit]-Sc4r3cr0w",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rock hires 24-48",
|
||||||
|
title: "Linkin Park - From Zero (2024) [24Bit-48kHz] FLAC [PMEDIA]",
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 48000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hiphop hires 24-96",
|
||||||
|
title: "J. Cole - The Fall-Off (2026 Hip Hop Rap) [Flac 24-96]",
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 96000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "minimal format",
|
||||||
|
title: "Bjork-Bastards.2012.FLAC-NewAlbumReleases",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vinyl hires",
|
||||||
|
title: "Gorillaz - Demon Days [Live From The Apollo Theater] [2025] [Vinyl Hi-Res] [FLAC-24bit]-Sc4r3cr0w",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cd with log",
|
||||||
|
title: "Linkin Park - Meteora (Tracks, Log, Cue, Scans) (2003) [FLAC] 88",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rock 16-44",
|
||||||
|
title: "Heart - Jupiters Darling (2004 Rock) [Flac 16-44]",
|
||||||
|
wantBitDepth: 16,
|
||||||
|
wantSampleRate: 44000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := p.Parse(tt.title)
|
||||||
|
|
||||||
|
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
|
||||||
|
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
|
||||||
|
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenericParser_ParseTorrent(t *testing.T) {
|
||||||
|
p := tracker.NewGenericParser()
|
||||||
|
|
||||||
|
makeFlacFiles := func(count int, sizeMB float64) []testFile {
|
||||||
|
files := make([]testFile, count)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = testFile{
|
||||||
|
path: fmt.Sprintf("%02d - Track %d.flac", i+1, i+1),
|
||||||
|
size: int64(sizeMB * 1024 * 1024),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
makeMp3Files := func(count int, sizeMB float64) []testFile {
|
||||||
|
files := make([]testFile, count)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = testFile{
|
||||||
|
path: fmt.Sprintf("%02d - Track %d.mp3", i+1, i+1),
|
||||||
|
size: int64(sizeMB * 1024 * 1024),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
torrentName string
|
||||||
|
files []testFile
|
||||||
|
album *metadataPb.Album
|
||||||
|
wantFormat release.AudioFormat
|
||||||
|
wantAudioFileCount int
|
||||||
|
wantHasCoverArt bool
|
||||||
|
wantHasCueSheet bool
|
||||||
|
wantHasRipLog bool
|
||||||
|
wantSource release.Source
|
||||||
|
wantInfoHashEmpty bool
|
||||||
|
wantBitDepth int
|
||||||
|
wantSampleRate int
|
||||||
|
wantTrackNames []string
|
||||||
|
wantArtist string
|
||||||
|
wantAlbum string
|
||||||
|
wantYear int
|
||||||
|
wantType release.Type
|
||||||
|
wantGenres []string
|
||||||
|
wantLabel string
|
||||||
|
wantParseErrors bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "flac album with cover cue log",
|
||||||
|
torrentName: "Test Artist - Test Album (2024) [FLAC]",
|
||||||
|
files: append(append(makeFlacFiles(12, 30),
|
||||||
|
testFile{path: "cover.jpg", size: 500000},
|
||||||
|
testFile{path: "album.cue", size: 2000}),
|
||||||
|
testFile{path: "rip.log", size: 5000}),
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Test Album",
|
||||||
|
AlbumType: "Album",
|
||||||
|
ReleaseDate: "2024-01-15",
|
||||||
|
TotalTracks: 12,
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Test Artist"}}},
|
||||||
|
Genres: []*metadataPb.Genre{{Name: "Rock"}},
|
||||||
|
Label: &metadataPb.Label{Name: "Test Label"},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatFLAC,
|
||||||
|
wantAudioFileCount: 12,
|
||||||
|
wantHasCoverArt: true,
|
||||||
|
wantHasCueSheet: true,
|
||||||
|
wantHasRipLog: true,
|
||||||
|
wantSource: release.SourceCD,
|
||||||
|
wantArtist: "Test Artist",
|
||||||
|
wantAlbum: "Test Album",
|
||||||
|
wantYear: 2024,
|
||||||
|
wantType: release.TypeAlbum,
|
||||||
|
wantGenres: []string{"Rock"},
|
||||||
|
wantLabel: "Test Label",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mp3 album with cover",
|
||||||
|
torrentName: "Artist - MP3 Album (2023)",
|
||||||
|
files: append(makeMp3Files(10, 10),
|
||||||
|
testFile{path: "cover.jpg", size: 300000}),
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "MP3 Album",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
ReleaseDate: "2023-05-20",
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatMP3,
|
||||||
|
wantAudioFileCount: 10,
|
||||||
|
wantHasCoverArt: true,
|
||||||
|
wantHasCueSheet: false,
|
||||||
|
wantHasRipLog: false,
|
||||||
|
wantArtist: "Artist",
|
||||||
|
wantAlbum: "MP3 Album",
|
||||||
|
wantYear: 2023,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed format dominant wins",
|
||||||
|
torrentName: "Mixed Format Album",
|
||||||
|
files: append(makeFlacFiles(10, 30),
|
||||||
|
testFile{path: "bonus1.mp3", size: 10485760},
|
||||||
|
testFile{path: "bonus2.mp3", size: 10485760}),
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Mixed Format Album",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatFLAC,
|
||||||
|
wantAudioFileCount: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single file torrent flac",
|
||||||
|
torrentName: "Single Track.flac",
|
||||||
|
files: []testFile{{path: "Single Track.flac", size: 50 * 1024 * 1024}},
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Single Track",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatFLAC,
|
||||||
|
wantAudioFileCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single file torrent mp3",
|
||||||
|
torrentName: "Single.mp3",
|
||||||
|
files: []testFile{{path: "Single.mp3", size: 10 * 1024 * 1024}},
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Single",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatMP3,
|
||||||
|
wantAudioFileCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no audio files",
|
||||||
|
torrentName: "Not Music",
|
||||||
|
files: []testFile{
|
||||||
|
{path: "readme.txt", size: 1000},
|
||||||
|
{path: "image.jpg", size: 500000},
|
||||||
|
},
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Not Music",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Someone"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatUnknown,
|
||||||
|
wantAudioFileCount: 0,
|
||||||
|
wantHasCoverArt: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hires in title",
|
||||||
|
torrentName: "Artist - Album (2024) [24Bit-96kHz] FLAC",
|
||||||
|
files: makeFlacFiles(12, 100),
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Album",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatFLAC,
|
||||||
|
wantAudioFileCount: 12,
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 96000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "source from title",
|
||||||
|
torrentName: "Artist - Album [WEB] FLAC",
|
||||||
|
files: makeFlacFiles(10, 30),
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Album",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatFLAC,
|
||||||
|
wantAudioFileCount: 10,
|
||||||
|
wantSource: release.SourceWEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "track names cleaned",
|
||||||
|
torrentName: "Artist - Album",
|
||||||
|
files: []testFile{
|
||||||
|
{path: "01 - First Track.flac", size: 30 * 1024 * 1024},
|
||||||
|
{path: "02 - Second Track.flac", size: 30 * 1024 * 1024},
|
||||||
|
},
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Album",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatFLAC,
|
||||||
|
wantAudioFileCount: 2,
|
||||||
|
wantTrackNames: []string{"First Track", "Second Track"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metadata fills release fields",
|
||||||
|
torrentName: "Test Torrent",
|
||||||
|
files: makeFlacFiles(8, 30),
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Metadata Album",
|
||||||
|
AlbumType: "EP",
|
||||||
|
ReleaseDate: "2020-06-15",
|
||||||
|
TotalTracks: 8,
|
||||||
|
TotalDiscs: 1,
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Metadata Artist"}}},
|
||||||
|
Genres: []*metadataPb.Genre{{Name: "Electronic"}, {Name: "Ambient"}},
|
||||||
|
Label: &metadataPb.Label{Name: "Metadata Label"},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatFLAC,
|
||||||
|
wantAudioFileCount: 8,
|
||||||
|
wantArtist: "Metadata Artist",
|
||||||
|
wantAlbum: "Metadata Album",
|
||||||
|
wantYear: 2020,
|
||||||
|
wantType: release.TypeEP,
|
||||||
|
wantGenres: []string{"Electronic", "Ambient"},
|
||||||
|
wantLabel: "Metadata Label",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty torrent data",
|
||||||
|
torrentName: "",
|
||||||
|
files: nil,
|
||||||
|
album: &metadataPb.Album{
|
||||||
|
Title: "Album Only",
|
||||||
|
ReleaseDate: "2022-01-01",
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist Only"}}},
|
||||||
|
},
|
||||||
|
wantFormat: release.FormatUnknown,
|
||||||
|
wantAudioFileCount: 0,
|
||||||
|
wantInfoHashEmpty: true,
|
||||||
|
wantArtist: "Artist Only",
|
||||||
|
wantAlbum: "Album Only",
|
||||||
|
wantYear: 2022,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid torrent data",
|
||||||
|
torrentName: "invalid",
|
||||||
|
files: nil,
|
||||||
|
album: &metadataPb.Album{Title: "Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}},
|
||||||
|
wantArtist: "Artist",
|
||||||
|
wantAlbum: "Album",
|
||||||
|
wantParseErrors: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := tracker.NewGenericParser()
|
||||||
|
|
||||||
|
makeFlacFiles := func(count int, avgSizeMB float64) []testFile {
|
||||||
|
files := make([]testFile, count)
|
||||||
|
size := int64(avgSizeMB * 1024 * 1024)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = testFile{path: fmt.Sprintf("%02d - Track %d.flac", i+1, i+1), size: size}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
makeMp3Files := func(count int, avgSizeMB float64) []testFile {
|
||||||
|
files := make([]testFile, count)
|
||||||
|
size := int64(avgSizeMB * 1024 * 1024)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = testFile{path: fmt.Sprintf("%02d - Track %d.mp3", i+1, i+1), size: size}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
torrentName string
|
||||||
|
files []testFile
|
||||||
|
wantBitDepth int
|
||||||
|
wantSampleRate int
|
||||||
|
wantBitrate string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "flac 16/44.1 from small files",
|
||||||
|
torrentName: "Artist - Album FLAC",
|
||||||
|
files: makeFlacFiles(12, 30),
|
||||||
|
wantBitDepth: 16,
|
||||||
|
wantSampleRate: 44100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flac 24/48 from medium files",
|
||||||
|
torrentName: "Artist - Album FLAC",
|
||||||
|
files: makeFlacFiles(12, 50),
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 48000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flac 24/96 from large files",
|
||||||
|
torrentName: "Artist - Album FLAC",
|
||||||
|
files: makeFlacFiles(12, 100),
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 96000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flac 24/192 from very large files",
|
||||||
|
torrentName: "Artist - Album FLAC",
|
||||||
|
files: makeFlacFiles(12, 200),
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 192000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title overrides heuristic",
|
||||||
|
torrentName: "Artist - Album [24Bit-48kHz] FLAC",
|
||||||
|
files: makeFlacFiles(12, 30),
|
||||||
|
wantBitDepth: 24,
|
||||||
|
wantSampleRate: 48000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mp3 320kbps from large files",
|
||||||
|
torrentName: "Artist - Album MP3",
|
||||||
|
files: makeMp3Files(12, 10),
|
||||||
|
wantBitrate: "320 kbps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mp3 128kbps from small files",
|
||||||
|
torrentName: "Artist - Album MP3",
|
||||||
|
files: makeMp3Files(12, 3.5),
|
||||||
|
wantBitrate: "128 kbps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mp3 title overrides",
|
||||||
|
torrentName: "Artist - Album 320 kbps MP3",
|
||||||
|
files: makeMp3Files(12, 3.5),
|
||||||
|
wantBitrate: "320 kbps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no audio files skips deduction",
|
||||||
|
torrentName: "Artist - Album",
|
||||||
|
files: []testFile{
|
||||||
|
{path: "cover.jpg", size: 500000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aac files no deduction",
|
||||||
|
torrentName: "Artist - Album",
|
||||||
|
files: func() []testFile {
|
||||||
|
files := make([]testFile, 12)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = testFile{path: fmt.Sprintf("%02d.aac", i+1), size: 50 * 1024 * 1024}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
album := &metadataPb.Album{
|
||||||
|
Title: "Album",
|
||||||
|
TotalTracks: 12,
|
||||||
|
Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data := buildTorrentData(tt.torrentName, tt.files)
|
||||||
|
r := p.ParseTorrent(data, album)
|
||||||
|
|
||||||
|
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.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
|
||||||
|
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
|
||||||
|
}
|
||||||
|
if tt.name == "no audio files skips deduction" || tt.name == "aac files no deduction" {
|
||||||
|
if r.BitDepth != 0 || r.SampleRate != 0 || r.Bitrate != "" {
|
||||||
|
t.Errorf("expected no deduction, got BitDepth=%d, SampleRate=%d, Bitrate=%q",
|
||||||
|
r.BitDepth, r.SampleRate, r.Bitrate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user