Compare commits

..

51 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 10:26:37 +02:00
Alexander f5e2f764b5 Optimize GetArtists: parallel artist processing, batch album upserts, batch download lookups, retry on metadata calls 2026-05-10 00:06:58 +02:00
Alexander 6320f37240 Deduplicate GetAlbum response: merge release info into AlbumDetail, track release into TrackDetail 2026-05-09 23:23:34 +02:00
Alexander 2740585261 Add album/track releases with audio analysis, AnalyzeAlbumRelease RPC, Docker path auto-resolution, release parsing decision tree 2026-05-09 23:16:59 +02:00
Alexander 1e8506f146 Deduce bit depth, sample rate, and bitrate from file sizes; add comprehensive parser tests 2026-05-09 22:09:17 +02:00
Alexander 7fa859e815 Remove rutracker parser, replace with GenericParser for all indexer results 2026-05-09 21:50:55 +02:00
Alexander ef75b9bfba Share single torrent client across all services, eliminate triple qBittorrent login on startup 2026-05-09 21:42:53 +02:00
Alexander 758a4b909a Leftovers 2026-05-09 21:31:24 +02:00
Alexander 31ec3f9826 Add MonitorAlbum component tests: 21 cases covering all flow diagrams (bufconn + testcontainers + hand-rolled mocks) 2026-05-09 21:31:09 +02:00
Alexander 6f31698006 Fix monitor state: never downgrade on upsert, explicitly set monitored on MonitorAlbum 2026-05-09 20:26:38 +02:00
Alexander 3ce6e23421 Fix duplicate download insert: handle NULL columns in download scan, check by torrent ID, enrich MonitorAlbum response, recover orphaned downloads on startup 2026-05-09 20:13:43 +02:00
Alexander cca404bcc0 Enrich MonitorAlbum response, prevent duplicate downloads, recover orphaned jobs on startup 2026-05-09 20:01:53 +02:00
Alexander 5257ed0f1b Fix album persistence by passing artist DB ID directly to PersistAlbum 2026-05-09 11:18:52 +02:00
Alexander 8c60fe5e35 Add GetAlbum RPC with track details and persist metadata on discovery 2026-05-09 10:47:06 +02:00
Alexander e61e58be72 Expand GetArtists with album details, download info, and generic MonitorState enum 2026-05-08 23:00:42 +02:00
Alexander e49cc25372 Add GetArtists RPC with artist monitor state (monitored/unmonitored/excluded) 2026-05-08 22:27:56 +02:00
Alexander 60c94935b2 Persist metadata to DB, poll download worker, metadata service layer 2026-05-08 11:00:04 +02:00
Alexander 66264e1314 Add database schema, ERD, and repository layer 2026-05-08 10:03:28 +02:00
Alexander 84a6fe8ec7 Refactor MonitorAlbum into focused methods 2026-05-07 23:28:35 +02:00
Alexander 8ad2734964 Implement MonitorAlbum: search, parse, filter by quality, add best to qbittorrent 2026-05-07 23:21:21 +02:00
Alexander 79f3f145de Add indexer cache with River queue for scheduled refresh 2026-05-07 21:41:17 +02:00
Alexander 2041c154cf Add the proxing to metadata-agregator 2026-05-07 12:00:37 +02:00
Alexander 97a57c10fd Added add endpoint 2026-05-07 10:27:20 +02:00
Alexander 6071bc7980 Implement the list endpoint for qbittorrent 2026-05-06 22:53:55 +02:00
Alexander 36416081c1 Create torrent proto stub 2026-05-06 22:26:40 +02:00
Alexander 3249bdc35c Add gRPC observability: logging, metrics, recovery interceptors 2026-05-06 21:58:24 +02:00
Alexander 67f46f740b Remove HTTP/REST server, keep gRPC only 2026-05-06 21:45:48 +02:00
Alexander 5fa46b2890 Add attr to differentiate trackers for future 2026-05-06 21:42:05 +02:00
Alexander b8fcbacb07 Update rutracker categories 2026-05-06 21:27:03 +02:00
Alexander 2400c6345a Refactor return type of the search 2026-05-05 14:41:45 +02:00
Alexander b41ea7d023 More parser tests + fixes 2026-05-04 23:17:38 +02:00
Alexander bfef1b6c79 Implement Jackett search entpoint 2026-05-04 22:48:14 +02:00
Alexander 8ffa92276e Add Jacket indexer with capabilities implemented 2026-05-04 18:40:31 +02:00
Alexander 32eb8c931e Create indexer proto 2026-05-04 16:45:22 +02:00
Alexander f8040ec088 Default values for config 2026-05-04 16:27:04 +02:00
Alexander 268ee57913 Grpc hello service implementation 2026-05-04 14:56:05 +02:00
Alexander 6baa895c6c Configure routing 2026-05-03 16:10:24 +02:00
Alexander 58aa4ef23b Added config, added logging 2026-05-03 15:49:35 +02:00
Alexander 46c2f6541f More removing old code 2026-05-03 13:46:21 +02:00
140 changed files with 18738 additions and 3290 deletions
+26
View File
@@ -4,3 +4,29 @@ config.yaml
/server
/vendor
pkg/metadatapb/
# Ignore all
*
# Unignore all with extensions
!*.*
# Unignore all dirs
!*/
### Above combination will ignore all files without extension ###
# Ignore files with extension `.class` & `.sm`
*.class
*.sm
# Ignore `bin` dir
bin/
# or
*/bin/*
# Ignore config.yaml
config.yaml
# Ignore protobuf generated files
gen/
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Analyze Album
type: grpc
seq: 10
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/AnalyzeAlbumRelease
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"album_id": "1d51e4a7-1a8b-4160-bd08-1aee658a991a"
}
'''
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Get Album
type: grpc
seq: 9
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/GetAlbum
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"album_id": "1d51e4a7-1a8b-4160-bd08-1aee658a991a"
}
'''
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Get Artist Albums
type: grpc
seq: 11
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/GetArtistAlbums
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"artist_id": "27e2997f-f7a1-4353-bcc4-57b9274fa9a4"
}
'''
}
+20
View File
@@ -0,0 +1,20 @@
meta {
name: Get Artists
type: grpc
seq: 7
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/GetArtists
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{}
'''
}
+23
View File
@@ -0,0 +1,23 @@
meta {
name: Hello Echo
type: grpc
seq: 3
}
grpc {
url: localhost:3000
method: /music_agregator.hello.v1.HelloService/Echo
body: grpc
protoPath: ../proto/music_agregator/hello/v1/service.proto
auth: none
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"msg": "hello"
}
'''
}
+21
View File
@@ -0,0 +1,21 @@
meta {
name: Hello Ping
type: grpc
seq: 1
}
grpc {
url: localhost:8081
method: /music_agregator.hello.v1.HelloService/Ping
body: grpc
protoPath: ../proto/music_agregator/hello/v1/service.proto
auth: none
methodType: unary
}
body:grpc {
name: message 1
content: '''
{}
'''
}
+24
View File
@@ -0,0 +1,24 @@
meta {
name: Capabilities
type: grpc
seq: 1
}
grpc {
url: localhost:3000
method: /music_agregator.indexer.v1.IndexerService/Capabilities
body: grpc
protoPath: ../proto/music_agregator/indexer/v1/indexer.proto
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"indexer": "rutracker"
}
'''
}
+25
View File
@@ -0,0 +1,25 @@
meta {
name: Search
type: grpc
seq: 2
}
grpc {
url: localhost:3000
method: /music_agregator.indexer.v1.IndexerService/Search
body: grpc
protoPath: ../proto/music_agregator/indexer/v1/indexer.proto
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"tracker": "",
"query": "Metallica",
"limit": 1
}
'''
}
+8
View File
@@ -0,0 +1,8 @@
meta {
name: Indexer
seq: 7
}
auth {
mode: inherit
}
+20
View File
@@ -0,0 +1,20 @@
meta {
name: Capabilities
type: http
seq: 2
}
get {
url: http://localhost:9117/api/v2.0/indexers/rutracker/results/torznab/api?apikey=3jfvdvt1etzz36drkw5id5sb95sc47fi&t=caps
body: none
auth: inherit
}
params:query {
apikey: 3jfvdvt1etzz36drkw5id5sb95sc47fi
t: caps
}
settings {
encodeUrl: true
}
+23
View File
@@ -0,0 +1,23 @@
meta {
name: Search
type: http
seq: 1
}
get {
url: http://localhost:9117/api/v2.0/indexers/all/results/torznab?apikey=3jfvdvt1etzz36drkw5id5sb95sc47fi&limit=2&q=Metallica&t=search
body: none
auth: inherit
}
params:query {
apikey: 3jfvdvt1etzz36drkw5id5sb95sc47fi
limit: 2
q: Metallica
t: search
}
settings {
encodeUrl: true
timeout: 0
}
+8
View File
@@ -0,0 +1,8 @@
meta {
name: Jackett
seq: 5
}
auth {
mode: inherit
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Get Album
type: grpc
seq: 4
}
grpc {
url: localhost:3000
method: /metadata.v1.MetadataService/GetAlbum
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"id": "a0b7b436-94db-4df6-8c5f-bc0e5932a90e"
}
'''
}
@@ -0,0 +1,22 @@
meta {
name: Get Artist Albums
type: grpc
seq: 3
}
grpc {
url: localhost:3000
method: /metadata.v1.MetadataService/GetArtistAlbums
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"artist_id": "65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab"
}
'''
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Get Artist
type: grpc
seq: 2
}
grpc {
url: localhost:3000
method: /metadata.v1.MetadataService/GetArtist
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"id": "65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab"
}
'''
}
+30
View File
@@ -0,0 +1,30 @@
meta {
name: Search Albums
type: grpc
seq: 5
}
grpc {
url: localhost:3000
method: /metadata.v1.MetadataService/SearchAlbums
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"query": "desidero",
"artist": "corrigo",
"limit": 770,
"offset": 396,
"provider": 0,
"album_types": [
"spiculum",
"spiculum"
]
}
'''
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Search Artists
type: grpc
seq: 1
}
grpc {
url: localhost:3000
method: /metadata.v1.MetadataService/SearchArtists
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"query": "Metallica"
}
'''
}
+8
View File
@@ -0,0 +1,8 @@
meta {
name: Metadata Agregator
seq: 6
}
auth {
mode: inherit
}
+26
View File
@@ -0,0 +1,26 @@
meta {
name: Monitor Album
type: grpc
seq: 5
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/MonitorAlbum
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"album_id": "a0b7b436-94db-4df6-8c5f-bc0e5932a90e",
"indexer_options": {
"tracker": ""
},
"quality_type": 1
}
'''
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: SearchArtists
type: grpc
seq: 10
}
grpc {
url: localhost:3000
method: /music_agregator.v1.MusicAgregatorService/SearchArtists
body: grpc
auth: inherit
methodType: unary
}
body:grpc {
name: message 1
content: '''
{
"query": "metal"
}
'''
}
+27
View File
@@ -0,0 +1,27 @@
{
"version": "1",
"name": "Music Agregator",
"type": "collection",
"protobuf": {
"importPaths": [
{
"path": "../proto",
"enabled": true
}
],
"protoFiles": [
{
"path": "../proto/music_agregator/hello/v1/service.proto",
"type": "file"
},
{
"path": "../proto/music_agregator/indexer/v1/indexer.proto",
"type": "file"
}
]
},
"presets": {
"requestType": "grpc",
"requestUrl": ""
}
}
View File
+11
View File
@@ -0,0 +1,11 @@
version: v2
inputs:
- directory: proto
- directory: ../metadata-agregator/proto
plugins:
- local: protoc-gen-go
out: gen
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: gen
opt: paths=source_relative
+9
View File
@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"os"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
"github.com/jackc/pgx/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/riverqueue/river"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
"gopkg.in/yaml.v2"
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
"homelab.lan/music-agregator/internal"
"homelab.lan/music-agregator/internal/analysis"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/hello"
"homelab.lan/music-agregator/internal/indexer"
"homelab.lan/music-agregator/internal/metadata"
"homelab.lan/music-agregator/internal/torrent"
"homelab.lan/music-agregator/internal/workers"
)
func main() {
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).
With().Timestamp().Logger()
configPath := flag.String("config", "", "Path to the config file")
flag.Parse()
cfg, err := parseConfig(configPath)
if err != nil {
log.Fatal().Err(err).Msg("Failed to load config")
}
log.Info().Interface("config", cfg).Msg("Loaded config")
serveGrpc(*cfg)
}
func interceptorLogger(l zerolog.Logger) logging.Logger {
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
l := l.With().Fields(fields).Logger()
switch lvl {
case logging.LevelDebug:
l.Debug().Msg(msg)
case logging.LevelInfo:
l.Info().Msg(msg)
case logging.LevelWarn:
l.Warn().Msg(msg)
case logging.LevelError:
l.Error().Msg(msg)
default:
l.Info().Msg(msg)
}
})
}
func setupDatabase(ctx context.Context, cfg config.Config) *database.DB {
db, err := database.New(ctx, cfg.Database.URL)
if err != nil {
log.Fatal().Err(err).Msg("failed to connect to database")
}
return db
}
type riverSetup struct {
client *river.Client[pgx.Tx]
cacheRefreshWorker *indexer.CacheRefreshWorker
}
func setupRiver(ctx context.Context, cfg config.Config, db *database.DB, torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper) *riverSetup {
cacheWorker := &indexer.CacheRefreshWorker{}
pollWorker := &workers.PollDownloadWorker{
Downloads: database.NewDownloadRepository(db.Pool),
DownloadFiles: database.NewDownloadFileRepository(db.Pool),
AlbumReleases: database.NewAlbumReleaseRepository(db.Pool),
TrackReleases: database.NewTrackReleaseRepository(db.Pool),
TorrentClient: torrentClient,
PathMapper: pathMapper,
Analyzer: analysis.NewReleaseAnalyzer(db),
}
riverWorkers := river.NewWorkers()
river.AddWorker(riverWorkers, cacheWorker)
river.AddWorker(riverWorkers, pollWorker)
riverClient, err := river.NewClient(riverpgxv5.New(db.Pool), &river.Config{
Queues: map[string]river.QueueConfig{
river.QueueDefault: {MaxWorkers: 4},
},
Workers: riverWorkers,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create River client")
}
cacheWorker.RiverClient = riverClient
pollWorker.RiverClient = riverClient
if err := riverClient.Start(ctx); err != nil {
log.Fatal().Err(err).Msg("failed to start River client")
}
log.Info().Msg("River queue started")
pollWorker.RecoverOrphanedDownloads(ctx)
return &riverSetup{
client: riverClient,
cacheRefreshWorker: cacheWorker,
}
}
func serveGrpc(config config.Config) {
srvMetrics := grpcprom.NewServerMetrics(
grpcprom.WithServerHandlingTimeHistogram(
grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 10, 30}),
),
)
logOpts := []logging.Option{
logging.WithLogOnEvents(logging.StartCall, logging.FinishCall),
}
recoveryOpts := []recovery.Option{
recovery.WithRecoveryHandler(func(p any) (err error) {
log.Error().Interface("panic", p).Msg("recovered from panic")
return status.Errorf(codes.Internal, "internal error")
}),
}
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
srvMetrics.UnaryServerInterceptor(),
logging.UnaryServerInterceptor(interceptorLogger(log.Logger), logOpts...),
recovery.UnaryServerInterceptor(recoveryOpts...),
),
grpc.ChainStreamInterceptor(
srvMetrics.StreamServerInterceptor(),
logging.StreamServerInterceptor(interceptorLogger(log.Logger), logOpts...),
recovery.StreamServerInterceptor(recoveryOpts...),
),
)
ctx := context.Background()
db := setupDatabase(ctx, config)
defer db.Close()
torrentClient, err := torrent.NewTorrentClient(config)
if err != nil {
log.Fatal().Err(err).Msg("failed to create torrent client")
}
pathMapper, err := torrent.NewPathMapper(config.Torrent.ContainerName, torrentClient)
if err != nil {
log.Fatal().Err(err).Msg("failed to create path mapper")
}
rs := setupRiver(ctx, config, db, torrentClient, pathMapper)
musiscAgregatorSeerver, err := internal.NewMusicAgregatorServer(config, rs.client, torrentClient, pathMapper, db)
if err != nil {
log.Fatal().Err(err).Msg("failed to create MusicAgregatorServer")
}
indexerServer, err := indexer.NewIndexerServer(config, rs.client, rs.cacheRefreshWorker)
if err != nil {
log.Fatal().Err(err).Msg("failed to create IndexerServer")
}
torrentServer := torrent.NewTorrentServer(torrentClient)
metadataServer, err := metadata.NewMetadataServer(config)
if err != nil {
log.Fatal().Err(err).Msg("failed to create MetadataServer")
}
services := []internal.Registrable{
hello.NewHelloServer(),
indexerServer,
torrentServer,
metadataServer,
musiscAgregatorSeerver,
}
for _, service := range services {
service.Register(server)
}
srvMetrics.InitializeMetrics(server)
prometheus.MustRegister(srvMetrics)
reflection.Register(server)
go func() {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
log.Info().Msg("Prometheus metrics available at :9090/metrics")
if err := http.ListenAndServe(":9090", mux); err != nil {
log.Fatal().Err(err).Msg("Failed to start metrics server")
}
}()
listener, err := net.Listen("tcp", fmt.Sprintf("%v:%v", config.App.Host, config.App.Port))
if err != nil {
log.Fatal().Err(err).Msg("Failed to listen")
}
log.Info().Str("addr", listener.Addr().String()).Msg("gRPC server listening")
server.Serve(listener)
}
func parseConfig(path *string) (*config.Config, error) {
f, err := os.Open(*path)
if err != nil {
log.Error().Str("path", *path).Msg("Unable to opent config by path")
return nil, err
}
defer f.Close()
cfg := config.NewConfig()
decoder := yaml.NewDecoder(f)
err = decoder.Decode(&cfg)
if err != nil {
// processError(err)
}
return cfg, nil
}
+2 -2
View File
@@ -7,8 +7,8 @@ database:
metadata:
endpoint: "http://localhost:50051"
indexers:
- name: "Jackett"
indexer:
name: "Jackett"
indexer_type: jackett # jackett, prowlarr, or torznab
url: "http://localhost:9117"
api_key: "your-jackett-api-key"
@@ -0,0 +1,112 @@
CREATE TYPE monitor_state AS ENUM ('unmonitored', 'monitored', 'excluded');
CREATE TYPE download_state AS ENUM ('pending', 'downloading', 'completed', 'failed', 'seeding');
CREATE TABLE artists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
artist_type VARCHAR(50) NOT NULL,
country VARCHAR(10),
genres TEXT[] NOT NULL DEFAULT '{}',
image_url TEXT,
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_artists_monitor_state ON artists(monitor_state);
CREATE TABLE albums (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) NOT NULL UNIQUE,
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
album_type VARCHAR(50) NOT NULL,
release_date DATE,
total_tracks INTEGER NOT NULL DEFAULT 0,
total_discs INTEGER NOT NULL DEFAULT 1,
label VARCHAR(255),
genres TEXT[] NOT NULL DEFAULT '{}',
cover_url TEXT,
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_albums_artist_id ON albums(artist_id);
CREATE INDEX idx_albums_monitor_state ON albums(monitor_state);
CREATE TABLE tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) NOT NULL UNIQUE,
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
isrc VARCHAR(20),
disc_number INTEGER NOT NULL DEFAULT 1,
track_number INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tracks_album_id ON tracks(album_id);
CREATE TABLE torrents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
info_hash VARCHAR(64) NOT NULL UNIQUE,
tracker VARCHAR(100) NOT NULL,
title TEXT NOT NULL,
format VARCHAR(20) NOT NULL,
quality VARCHAR(20) NOT NULL,
source VARCHAR(50),
bit_depth INTEGER,
sample_rate INTEGER,
seeders INTEGER NOT NULL DEFAULT 0,
peers INTEGER NOT NULL DEFAULT 0,
size BIGINT NOT NULL DEFAULT 0,
track_count INTEGER NOT NULL DEFAULT 0,
has_cover_art BOOLEAN NOT NULL DEFAULT false,
has_cue_sheet BOOLEAN NOT NULL DEFAULT false,
has_rip_log BOOLEAN NOT NULL DEFAULT false,
download_link TEXT,
torrent_file BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_torrents_album_id ON torrents(album_id);
CREATE TABLE downloads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
torrent_id UUID NOT NULL REFERENCES torrents(id) ON DELETE CASCADE,
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
format VARCHAR(20) NOT NULL,
quality VARCHAR(20) NOT NULL,
state download_state NOT NULL DEFAULT 'pending',
qbit_hash VARCHAR(64),
save_path TEXT,
error_message TEXT,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_downloads_album_id ON downloads(album_id);
CREATE INDEX idx_downloads_torrent_id ON downloads(torrent_id);
CREATE INDEX idx_downloads_state ON downloads(state);
CREATE TABLE download_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
download_id UUID NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
file_path TEXT NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
file_type VARCHAR(50) NOT NULL,
sha256_hash VARCHAR(64),
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_download_files_download_id ON download_files(download_id);
@@ -0,0 +1,33 @@
CREATE TABLE workflow_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
quality VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running',
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
running_lock BOOLEAN GENERATED ALWAYS AS (CASE WHEN status = 'running' THEN TRUE ELSE NULL END) STORED,
CONSTRAINT idx_workflow_runs_active UNIQUE (album_id, quality, running_lock)
);
CREATE INDEX idx_workflow_runs_status ON workflow_runs(status);
CREATE TABLE album_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seq BIGSERIAL NOT NULL,
workflow_run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
album_id UUID NOT NULL,
event_type VARCHAR(20) NOT NULL,
step VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
data_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_album_events_workflow ON album_events(workflow_run_id);
CREATE INDEX idx_album_events_album ON album_events(album_id);
CREATE INDEX idx_album_events_seq ON album_events(seq);
ALTER TYPE download_state ADD VALUE IF NOT EXISTS 'cancelled';
+5 -25
View File
@@ -1,23 +1,4 @@
services:
postgres:
image: postgres:16-alpine
container_name: music-aggregator-db
restart: unless-stopped
environment:
POSTGRES_USER: music
POSTGRES_PASSWORD: music
POSTGRES_DB: music_aggregator
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U music -d music_aggregator"]
interval: 10s
timeout: 5s
retries: 5
jackett:
image: lscr.io/linuxserver/jackett:latest
container_name: music-aggregator-jackett
@@ -52,11 +33,11 @@ services:
- "6881:6881"
- "6881:6881/udp"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:9999"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
@@ -76,7 +57,6 @@ services:
- downloads:/downloads
volumes:
postgres_data:
jackett_config:
jackett_downloads:
qbittorrent_config:
-225
View File
@@ -1,225 +0,0 @@
-- Music Aggregator Database Schema
-- Based on docs/erd.puml
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ══════════════════════════════════════════════════════════════
-- CONFIGURATION
-- ══════════════════════════════════════════════════════════════
CREATE TABLE quality_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
cutoff INT NOT NULL DEFAULT 0,
items JSONB NOT NULL DEFAULT '[]',
upgrade_allowed BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE metadata_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
primary_album_types JSONB NOT NULL DEFAULT '["Album", "EP"]',
secondary_album_types JSONB NOT NULL DEFAULT '[]',
release_statuses JSONB NOT NULL DEFAULT '["Official"]'
);
CREATE TABLE root_folders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
default_quality_profile_id UUID REFERENCES quality_profiles(id),
default_metadata_profile_id UUID REFERENCES metadata_profiles(id)
);
CREATE TABLE indexers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
implementation TEXT NOT NULL,
settings JSONB NOT NULL DEFAULT '{}',
enable_rss BOOLEAN NOT NULL DEFAULT true,
enable_search BOOLEAN NOT NULL DEFAULT true,
priority INT NOT NULL DEFAULT 25
);
CREATE TABLE download_clients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
implementation TEXT NOT NULL,
settings JSONB NOT NULL DEFAULT '{}',
protocol TEXT NOT NULL DEFAULT 'torrent',
priority INT NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT true
);
-- ══════════════════════════════════════════════════════════════
-- CORE MUSIC ENTITIES
-- ══════════════════════════════════════════════════════════════
CREATE TABLE artist_metadata (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
foreign_artist_id TEXT UNIQUE,
name TEXT NOT NULL,
sort_name TEXT,
disambiguation TEXT,
artist_type TEXT,
status TEXT,
overview TEXT,
images JSONB NOT NULL DEFAULT '[]',
links JSONB NOT NULL DEFAULT '[]',
genres JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE artists (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
quality_profile_id UUID REFERENCES quality_profiles(id),
metadata_profile_id UUID REFERENCES metadata_profiles(id),
root_folder_id UUID REFERENCES root_folders(id),
path TEXT,
monitored BOOLEAN NOT NULL DEFAULT true,
monitor_new_items TEXT NOT NULL DEFAULT 'all',
last_info_sync TIMESTAMPTZ,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE albums (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
foreign_album_id TEXT UNIQUE,
title TEXT NOT NULL,
clean_title TEXT,
disambiguation TEXT,
overview TEXT,
album_type TEXT,
release_date DATE,
images JSONB NOT NULL DEFAULT '[]',
genres JSONB NOT NULL DEFAULT '[]',
monitored BOOLEAN NOT NULL DEFAULT true,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE album_releases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
foreign_release_id TEXT UNIQUE,
title TEXT NOT NULL,
status TEXT,
duration_ms INT,
release_date DATE,
country TEXT[],
label TEXT[],
format TEXT,
track_count INT,
monitored BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE track_files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
path TEXT NOT NULL,
relative_path TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
file_hash TEXT,
audio_hash TEXT,
quality JSONB NOT NULL DEFAULT '{}',
media_info JSONB NOT NULL DEFAULT '{}',
scene_name TEXT,
release_group TEXT,
date_added TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tracks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_release_id UUID NOT NULL REFERENCES album_releases(id) ON DELETE CASCADE,
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
track_file_id UUID REFERENCES track_files(id) ON DELETE SET NULL,
foreign_track_id TEXT UNIQUE,
title TEXT NOT NULL,
track_number INT NOT NULL DEFAULT 1,
disc_number INT NOT NULL DEFAULT 1,
duration_ms INT,
explicit BOOLEAN NOT NULL DEFAULT false
);
-- ══════════════════════════════════════════════════════════════
-- DOWNLOAD TRACKING
-- ══════════════════════════════════════════════════════════════
CREATE TABLE wanted_albums (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_id UUID NOT NULL UNIQUE REFERENCES albums(id) ON DELETE CASCADE,
priority INT NOT NULL DEFAULT 0,
search_count INT NOT NULL DEFAULT 0,
last_searched_at TIMESTAMPTZ,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE download_queue (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artist_id UUID REFERENCES artists(id) ON DELETE SET NULL,
album_id UUID REFERENCES albums(id) ON DELETE SET NULL,
download_id TEXT,
title TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
size_left BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued',
progress REAL NOT NULL DEFAULT 0.0,
error_message TEXT,
protocol TEXT NOT NULL DEFAULT 'torrent',
indexer TEXT,
download_client TEXT,
torrent_hash TEXT,
output_path TEXT,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE blocklist (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
source_title TEXT NOT NULL,
quality JSONB NOT NULL DEFAULT '{}',
size BIGINT NOT NULL DEFAULT 0,
protocol TEXT,
indexer TEXT,
message TEXT,
torrent_hash TEXT,
date TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ══════════════════════════════════════════════════════════════
-- INDEXES
-- ══════════════════════════════════════════════════════════════
CREATE INDEX idx_artist_metadata_name ON artist_metadata(name);
CREATE INDEX idx_artist_metadata_foreign_id ON artist_metadata(foreign_artist_id);
CREATE INDEX idx_albums_artist ON albums(artist_metadata_id);
CREATE INDEX idx_albums_foreign_id ON albums(foreign_album_id);
CREATE INDEX idx_albums_release_date ON albums(release_date);
CREATE INDEX idx_album_releases_album ON album_releases(album_id);
CREATE INDEX idx_tracks_release ON tracks(album_release_id);
CREATE INDEX idx_tracks_artist ON tracks(artist_metadata_id);
CREATE INDEX idx_track_files_album ON track_files(album_id);
CREATE INDEX idx_track_files_hash ON track_files(file_hash);
CREATE INDEX idx_track_files_audio_hash ON track_files(audio_hash);
CREATE INDEX idx_wanted_albums_priority ON wanted_albums(priority DESC);
CREATE INDEX idx_download_queue_status ON download_queue(status);
CREATE INDEX idx_download_queue_album ON download_queue(album_id);
CREATE INDEX idx_blocklist_artist ON blocklist(artist_id);
CREATE INDEX idx_blocklist_torrent ON blocklist(torrent_hash);
-- ══════════════════════════════════════════════════════════════
-- DEFAULT DATA
-- ══════════════════════════════════════════════════════════════
INSERT INTO quality_profiles (name, cutoff, items, upgrade_allowed) VALUES
('Any', 0, '[]', true),
('Lossless', 1, '[{"quality": "FLAC", "allowed": true}, {"quality": "ALAC", "allowed": true}]', true),
('Standard', 2, '[{"quality": "MP3-320", "allowed": true}, {"quality": "MP3-VBR-V0", "allowed": true}]', true);
INSERT INTO metadata_profiles (name, primary_album_types, secondary_album_types, release_statuses) VALUES
('Standard', '["Album", "EP"]', '[]', '["Official"]'),
('All', '["Album", "EP", "Single", "Broadcast", "Other"]', '["Compilation", "Soundtrack", "Spokenword", "Interview", "Audiobook", "Live", "Remix", "DJ-mix", "Mixtape/Street", "Demo"]', '["Official", "Promotional", "Bootleg"]');
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
@startuml Event Bus Architecture
skinparam componentAlign center
title Event Bus: In-Process Pub/Sub Architecture
package "Publishers" {
[Workflow Goroutine 1\n(album A, LOSSLESS)] as WF1
[Workflow Goroutine 2\n(album B, LOSSY)] as WF2
}
database "PostgreSQL" as DB {
[workflow_runs] as WR
[album_events] as AE
}
package "Event Bus (in-memory)" {
[Topic: albumA:LOSSLESS] as T1
[Topic: albumB:LOSSY] as T2
[Global Subscribers] as GS
}
package "Subscribers" {
[MonitorAlbumStream\nClient A (album A)] as S1
[MonitorAlbumStream\nClient B (album A)] as S2
[SubscribeEvents\nClient C (global)] as S3
}
WF1 --> DB : 1. Write event\n(synchronous)
WF1 --> T1 : 2. Publish\n(async notification)
WF2 --> DB : 1. Write event
WF2 --> T2 : 2. Publish
T1 --> S1 : Ring buffer\n(per subscriber)
T1 --> S2 : Ring buffer
T1 --> GS
T2 --> GS
GS --> S3 : Ring buffer
note right of DB
**Source of truth.**
Events survive restarts.
Replay via seq numbers.
end note
note right of T1
**Ephemeral notification.**
Ring buffer per subscriber.
Slow subscribers: overwrite oldest.
No backpressure on publishers.
end note
note bottom of S1
Client disconnect removes
subscriber from topic.
Workflow continues.
end note
== Subscription Lifecycle ==
note as N1
**Subscribe flow:**
1. Client calls MonitorAlbumStream or SubscribeEvents
2. Server subscribes to EventBus (per-topic or global)
3. Server queries DB for historical events (replay)
4. Server bridges: EventBus → gRPC stream
5. On disconnect: cleanup func unsubscribes
**Topic cleanup:**
When last subscriber leaves AND workflow completed:
topic removed from EventBus map.
end note
== Recovery on Restart ==
note as N2
**Server restart recovery:**
1. Query workflow_runs WHERE status = 'running'
2. For each stale run:
- If active download exists → mark completed
- Otherwise → mark failed ("server restarted")
3. RecoverOrphanedDownloads reschedules poll jobs
4. New workflows start fresh (no goroutine resurrection)
end note
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,39 @@
@startuml MonitorAlbum - Already Owned (Early Return)
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum: Album Already Owned in Requested Quality
actor Client
participant "MusicAgregatorService" as Service
participant "MetadataService" as Metadata
database "metadata-agregator\n(gRPC)" as MetaGRPC
database "PostgreSQL" as DB
Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: Album
Metadata -> DB: albums.GetByExternalID()
DB --> Metadata: found (already persisted)
note right: Album exists from\nprevious MonitorAlbum\nor GetArtists discovery
Metadata --> Service: Album
Service -> DB: albums.GetByExternalID()
DB --> Service: dbAlbum
Service -> DB: albums.SetMonitorState(id, monitored)
note right #lightgreen: Always set monitored\nregardless of outcome
Service -> DB: downloads.HasAlbumInQuality(id, LOSSLESS, "16-44")
DB --> Service: true
note right #lightgreen: Found existing download\nwith state IN\n('completed', 'seeding')
Service -> Service: buildMonitorAlbumResponse()
Service -> DB: downloads.GetByAlbumID()
DB --> Service: download (completed, FLAC, /downloads)
Service -> DB: artists.GetByExternalID()
DB --> Service: artist
Service --> Client: MonitorAlbumResponse
note right #lightgreen: album: monitored, download info\nartist: monitored\nrelease: nil (no new search)\n\nNo indexer search.\nNo torrent client interaction.\nFast response.
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

@@ -0,0 +1,74 @@
@startuml MonitorAlbum - Indexer & Parse Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum Error: Indexer Search & Parse Failures
actor Client
participant "MusicAgregatorService" as Service
participant "IndexerService\n(Jackett)" as Indexer
participant "MagnetResolver" as Magnet
== Case 1: Indexer search fails (Jackett down / timeout) ==
Client -> Service: MonitorAlbum(album_id, quality)
note right: metadata fetch + persist succeeded
Service -> Indexer: Search("Artist Album", tracker)
Indexer --> Service: error (connection refused / timeout)
Service --> Client: error
note right #salmon: Full stop after metadata.\nAlbum is persisted & monitored\nbut no torrent found.
== Case 2: Indexer returns zero results ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Indexer: Search("Artist Album", tracker)
Indexer --> Service: SearchResponse (0 items)
Service -> Service: parseSearchResults → empty
Service -> Service: filterByQuality → empty
Service --> Client: MonitorAlbumResponse
note right #orange: Partial response.\nalbum + artist returned.\nrelease: nil.\nNo torrent added.
== Case 3: All results have no seeders ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Indexer: Search(...)
Indexer --> Service: SearchResponse (5 items, all seeders=0)
loop for each item
Service -> Service: item.Seeders == 0 → skip
note right: Logged as warning per item
end
Service -> Service: parsed = empty
Service -> Service: filterByQuality → empty
Service --> Client: MonitorAlbumResponse (no release)
== Case 4: All magnet resolves fail ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Indexer: Search(...)
Indexer --> Service: SearchResponse (3 items)
loop for each item
Service -> Magnet: Resolve(magnet_uri)
Magnet --> Service: error (timeout / no active peers)
Service -> Service: fallback to title parse
note right: Release parsed from title only.\nFormat may be "unknown".\nNo torrent data (nil).
end
Service -> Service: filterByQuality(parsed, LOSSLESS)
note right #orange: Title-parsed releases may have\nformat=unknown → not lossless\n→ filtered out
Service --> Client: MonitorAlbumResponse (no release)
== Case 5: No results match quality filter ==
Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS)
Service -> Indexer: Search(...)
Indexer --> Service: SearchResponse (3 items)
Service -> Service: parseSearchResults → 3 items (all MP3)
Service -> Service: filterByQuality(LOSSLESS) → empty
note right: All releases are lossy.\nQuality filter rejects all.
Service --> Client: MonitorAlbumResponse
note right #orange: album + artist returned.\nrelease: nil.\n"no releases match quality filter"
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,58 @@
@startuml MonitorAlbum - Metadata Fetch Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum Error: Metadata Fetch Failures
actor Client
participant "MusicAgregatorService" as Service
participant "MetadataService" as Metadata
database "metadata-agregator\n(gRPC)" as MetaGRPC
database "PostgreSQL" as DB
== Case 1: metadata-agregator unreachable ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: gRPC error (Unavailable)
Metadata --> Service: error: "fetching album: ..."
Service --> Client: error (Unavailable)
note right: Full stop.\nNo DB writes occur.\nNo indexer search.
== Case 2: Album not found in metadata ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: gRPC error (NotFound)
Metadata --> Service: error: "fetching album: ..."
Service --> Client: error (NotFound)
note right: Full stop.\nInvalid album_id.\nNo side effects.
== Case 3: Album found, but artist persist fails ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: Album
Metadata -> DB: albums.GetByExternalID()
DB --> Metadata: not found
Metadata -> DB: artists.Create()
DB --> Metadata: error (e.g. connection lost)
note right #salmon: Artist persist fails.\nLogged as warning.\nFlow continues.
Metadata -> DB: albums.Create()
note right #salmon: artistID is empty\n→ album persist skipped\n(no artist reference)
Metadata --> Service: Album (metadata only)
Service -> DB: albums.GetByExternalID()
DB --> Service: not found (album never persisted)
note right #salmon: dbAlbum is nil.\nMonitor state not set.\nOwnership check skipped.
Service -> Service: continues to indexer search...
note right: Flow proceeds but\ndownload persistence\nwill be skipped later\n(dbAlbum == nil)
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

@@ -0,0 +1,90 @@
@startuml MonitorAlbum - Async Poll Worker Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum: Async Download Poll Worker Failures
participant "River Queue" as River
participant "PollDownloadWorker" as Worker
participant "TorrentClient\n(qBittorrent)" as QBit
database "PostgreSQL" as DB
note over Worker: These occur asynchronously\nafter MonitorAlbum returns.\nClient already received response.
== Case 1: qBittorrent unreachable during poll ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: error (connection refused)
note right #orange: Logged as error.\nJob rescheduled.
Worker -> River: Insert(PollDownloadArgs)
note right: Reschedule after check_interval (30s).\nRetries indefinitely until\nqBit becomes available.
== Case 2: Torrent disappeared from qBittorrent ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: empty results
note right #salmon: Torrent was removed\nfrom qBit externally.
Worker -> DB: downloads.SetFailed(id, "torrent not found in client")
note right: Download marked as failed.\nNo further polls scheduled.\nNo retry.
== Case 3: Torrent in error state ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{state: "error"}
note right #salmon: qBit reports torrent error.\n(e.g. tracker unreachable,\ncorrupt data, disk full)
Worker -> DB: downloads.SetFailed(id, "torrent error state")
note right: Download marked as failed.\nNo further polls.\nTorrent remains in qBit.
== Case 4: Download completes, but SetCompleted fails ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{progress: 1.0, savePath: "/downloads"}
Worker -> DB: downloads.SetCompleted(id, "/downloads")
DB --> Worker: error (DB connection lost)
note right #salmon: Worker returns error.\nRiver will retry the job\n(built-in retry policy).\nDownload stays in\n"downloading" state.
== Case 5: File scan fails after completion ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{progress: 1.0, path: "/downloads/Album"}
Worker -> DB: downloads.SetCompleted(id, "/downloads")
DB --> Worker: OK
note right #lightgreen: Download marked completed.
Worker -> Worker: scanAndHashFiles("/downloads/Album")
Worker --> Worker: error (permission denied / path not found)
note right #orange: Logged as error.\nDownload IS completed.\nBut download_files NOT populated.\nGetAlbum won't show file info\nfor individual tracks.
== Case 6: File persist fails ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{progress: 1.0}
Worker -> DB: downloads.SetCompleted(id, savePath)
Worker -> Worker: scanAndHashFiles → 12 files
Worker -> DB: download_files.CreateBatch(files)
DB --> Worker: error (duplicate / constraint)
note right #orange: Download is completed.\nFiles not persisted.\nNon-fatal: returns nil.\nNo retry.
== Case 7: App crash during download (startup recovery) ==
note over River, Worker: Application restarts.\nRiver picks up persisted jobs.
River -> Worker: RecoverOrphanedDownloads()
Worker -> DB: downloads.GetActive()
DB --> Worker: [Download{state: downloading, hash: ...}]
Worker -> River: Insert(PollDownloadArgs, UniqueOpts{ByArgs})
note right #lightgreen: Deduplicated insert.\nIf River job already exists\n→ no duplicate.\nIf job was lost → recovered.\nPolling resumes.
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

@@ -0,0 +1,71 @@
@startuml MonitorAlbum - Torrent Client Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum Error: Torrent Client Failures
actor Client
participant "MusicAgregatorService" as Service
participant "TorrentClient\n(qBittorrent)" as QBit
database "PostgreSQL" as DB
note over Service: Metadata fetched, indexer searched,\nbest release selected.
== Case 1: qBittorrent unreachable ==
Service -> QBit: Find(hash)
QBit --> Service: error (connection refused)
note right: Find() fails → skip existence check
Service -> QBit: AddMagnet(uri)
QBit --> Service: error (connection refused)
Service --> Client: error
note right #salmon: Album is persisted & monitored.\nNo torrent added.\nNo download record created.
== Case 2: Torrent already exists in qBit ==
Service -> QBit: Find(hash)
QBit --> Service: [TorrentInfo{state: stalledUP}]
note right #lightgreen: Torrent found.\nSkip AddMagnet/AddTorrent.
Service -> DB: torrents.Create (upsert)
Service -> DB: torrents.GetByInfoHash
DB --> Service: savedTorrent
Service -> DB: downloads.GetActiveByTorrentID(torrent_id)
DB --> Service: Download{state: completed}
note right #lightgreen: Active download exists.\nSkip duplicate insert.
Service --> Client: MonitorAlbumResponse
note right: Returns album + artist + release.\nNo duplicate download created.\nNo error.
== Case 3: AddTorrent fails (no torrent data) ==
Service -> QBit: Find(hash)
QBit --> Service: not found
note right: best.torrentData is nil\n(magnet resolve failed,\nfell back to title parse)
Service -> Service: len(best.torrentData) == 0
Service --> Client: error "no torrent data available"
note right #salmon: Magnet link but no\ntorrent data resolved.\nCannot add to client.
== Case 4: Torrent persists, but download insert fails ==
Service -> QBit: AddMagnet(uri)
QBit --> Service: OK
Service -> DB: torrents.Create (upsert)
Service -> DB: torrents.GetByInfoHash
DB --> Service: savedTorrent
Service -> DB: downloads.GetActiveByTorrentID
DB --> Service: not found
Service -> DB: downloads.Create(download)
DB --> Service: error (constraint violation)
note right #salmon: Logged as error.\nTorrent is in qBit but\nno download record.\nResponse still returned\n(non-fatal within saveTorrentAndDownload).\nNo poll job scheduled.
Service --> Client: MonitorAlbumResponse
note right #orange: Response includes release info.\nDownload info may be missing.\nTorrent is downloading in qBit\nbut untracked in DB.
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,138 @@
@startuml MonitorAlbum Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
actor Client
participant "gRPC Server" as Server
participant "MusicAgregatorService" as Service
participant "MetadataService" as Metadata
database "metadata-agregator\n(gRPC)" as MetaGRPC
database "PostgreSQL" as DB
participant "IndexerService\n(Jackett)" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
participant "River Queue" as River
participant "PollDownloadWorker" as PollWorker
== 1. Fetch Album Metadata ==
Client -> Server: MonitorAlbum(album_id, quality, tracker)
Server -> Service: MonitorAlbum(ctx, req)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: Album (title, artists, genres, ...)
Metadata -> DB: albums.GetByExternalID(external_id)
note right: Check if album already persisted
DB --> Metadata: not found
Metadata -> DB: artists.Create(artist, state=monitored)
note right: Upsert artist\nnever downgrades\nmonitored/excluded
Metadata -> DB: albums.Create(album, state=monitored)
note right: Upsert album\nnever downgrades\nmonitored/excluded
Metadata --> Service: Album
== 2. Set Monitor State ==
Service -> DB: albums.GetByExternalID(external_id)
DB --> Service: dbAlbum
Service -> DB: albums.SetMonitorState(id, monitored)
note right: Explicitly mark\nalbum as monitored
== 3. Check If Already Owned ==
Service -> DB: downloads.HasAlbumInQuality(album_id, format, quality)
DB --> Service: false (not owned)
== 4. Search Indexers ==
Service -> Indexer: Search(artist + album title, tracker)
Indexer -> Indexer: Jackett API\n/api/v2.0/indexers/all/results
Indexer --> Service: SearchResponse (N items)
== 5. Parse & Resolve Releases ==
loop for each search result (with download link & seeders > 0)
alt magnet link
Service -> Magnet: Resolve(magnet_uri)
note right: DHT lookup, 30s timeout\n15s early exit if peers\nbut none active
Magnet --> Service: torrent metadata (files, hash, size)
Service -> Service: ParseTorrent(torrentData, album)
else HTTP torrent link
Service -> Service: downloadTorrentData(url)
Service -> Service: ParseTorrent(torrentData, album)
end
note right: Extract: format, bitDepth, sampleRate,\nsource, trackCount, coverArt, cueSheet, ripLog
end
== 6. Filter & Select Best ==
Service -> Service: filterByQuality(parsed, quality)
note right: Match LOSSLESS/LOSSY/UNSPECIFIED\nagainst release format
Service -> Service: selectBestRelease(filtered)
note right: Highest seeder count wins
== 7. Add to Torrent Client ==
Service -> QBit: Find(hash)
QBit --> Service: not found
alt magnet link
Service -> QBit: AddMagnet(magnet_uri)
else torrent file
Service -> QBit: AddTorrent(file)
end
QBit --> Service: OK
== 8. Persist Torrent & Download ==
Service -> DB: torrents.Create(torrent)
note right: Upsert on info_hash\nupdates seeders/peers
Service -> DB: torrents.GetByInfoHash(hash)
DB --> Service: savedTorrent (with DB id)
Service -> DB: downloads.GetActiveByTorrentID(torrent_id)
DB --> Service: not found (no active download)
Service -> DB: downloads.Create(download)
note right: state = "downloading"\nformat, quality, qbit_hash
DB --> Service: download (with DB id)
== 9. Schedule Download Poll ==
Service -> River: Insert(PollDownloadArgs)
note right: download_id, torrent_hash\ncheck_interval = 30s\nscheduled_at = now + 30s
River --> Service: job scheduled
== 10. Build & Return Response ==
Service -> DB: albums.GetByExternalID(external_id)
DB --> Service: dbAlbum (refreshed)
Service -> DB: downloads.GetByAlbumID(album_id)
DB --> Service: downloads (with state)
Service -> DB: artists.GetByExternalID(artist_external_id)
DB --> Service: dbArtist
Service --> Server: MonitorAlbumResponse
note right: album: id, title, monitor_state=monitored,\n download: state, format, quality\nartist: id, name, monitor_state\nrelease: hash, format, seeders, tracks
Server --> Client: MonitorAlbumResponse
== 11. Async: Download Polling (River Worker) ==
River -> PollWorker: Work(PollDownloadArgs)
PollWorker -> QBit: Find(hash)
QBit --> PollWorker: TorrentInfo (progress, state, path)
alt progress < 100%
PollWorker -> River: Insert(PollDownloadArgs)
note right: Reschedule after check_interval
else progress == 100%
PollWorker -> DB: downloads.SetCompleted(id, save_path)
PollWorker -> PollWorker: scanAndHashFiles(content_path)
note right: Walk directory, identify audio files\n(.flac, .mp3, .aac, ...)\nSHA-256 hash each file
PollWorker -> DB: download_files.CreateBatch(files)
note right: file_path, file_size, file_type,\nsha256_hash, verified_at
end
@enduml
@@ -0,0 +1,109 @@
@startuml MonitorAlbumStream - Already Owned Scenarios
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Already Owned Handling
actor Client
participant "monitorWorkflow" as Workflow
participant "EventBus" as Bus
database "PostgreSQL" as DB
participant "IndexerService" as Indexer
note over Client, Indexer #lightblue
All events are persisted to album_events table (DB first)
then published to EventBus for live subscribers.
In automatic mode, workflow runs as background goroutine.
end note
== Scenario A: Automatic Mode - Early Return ==
Client -> Workflow: StartMonitorRequest(mode=AUTOMATIC)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Fetching album metadata..."
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Got: Artist - Title"\nData: StreamAlbumInfo
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: StatusUpdate(COMPLETE)
note right #lightgreen: "Already owned"
Workflow ->> Client: MonitorAlbumResponse
note right: album: monitored\ndownload: existing info\nrelease: nil (no search)
note over Client, Workflow #lightblue
**Automatic Mode**: Skips search entirely.
Returns immediately with existing download info.
end note
== Scenario B: Manual Mode - User Confirms Continue ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false
Client -> Workflow: UserDecision(confirm=true)
note right #lightgreen: User chooses\nto continue
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
note right: Proceeds with normal flow...
Workflow -> Indexer: Search()
note right: ... continues to completion
== Scenario C: Manual Mode - User Skips ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"
Client -> Workflow: UserDecision(confirm=false)
note right #lightyellow: User chooses\nto skip
Workflow ->> Client: StatusUpdate(COMPLETE)
note right: "Skipped - already owned"
Workflow ->> Client: MonitorAlbumResponse
note right: album: monitored\ndownload: existing info\nrelease: nil
== Scenario D: Manual Mode - Timeout ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow ->> Client: PromptForDecision
note over Client, Workflow #lightyellow
Client does not respond within timeout (max: 300s)
end note
Workflow -> Workflow: Use default decision
note right: default: false\n(skip download)
Workflow ->> Client: StatusUpdate(COMPLETE)
note right: "Skipped - already owned"
Workflow ->> Client: MonitorAlbumResponse
@enduml
@@ -0,0 +1,99 @@
@startuml MonitorAlbumStream - Automatic Mode Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Automatic Mode (Fire-and-Forget)
actor Client
participant "gRPC Server" as Server
participant "WorkflowRegistry" as Registry
participant "EventBus" as Bus
participant "monitorWorkflow\n(background goroutine)" as Workflow
database "PostgreSQL" as DB
participant "MusicAgregatorService" as Service
participant "IndexerService" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
== 1. Initialize Stream ==
Client -> Server: MonitorAlbumStream()
Server -> Client: stream established
Client -> Server: StartMonitorRequest
note right: album_id, quality\nmode = AUTOMATIC
== 2. Start or Subscribe to Workflow ==
Server -> Registry: GetOrCreate(albumID, quality)
alt New workflow
Registry --> Server: (entry, created=true)
Server -> Bus: Subscribe(topic)
note right #lightblue: Subscribe BEFORE\nstarting goroutine\n(no missed events)
Server -> Workflow: go workflow.run(entry.Ctx)
note right #lightyellow: Background goroutine.\nDecoupled from stream context.\nClient can disconnect freely.
else Existing workflow
Registry --> Server: (entry, created=false)
Server -> Bus: Subscribe(topic)
Server -> DB: Replay album_events\n(since last seq)
DB --> Server: historical events
Server ->> Client: replayed events
end
== 3. Event Bridge (concurrent) ==
note over Client, Workflow #lightblue
**Left side**: Event bus → gRPC stream bridge
**Right side**: Workflow executing in background
Both run concurrently. Client disconnect only stops the bridge.
end note
|||
Workflow -> DB: album_events.Create(FETCHING_METADATA)
Workflow -> Bus: Publish(FETCHING_METADATA)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow -> Service: getAlbumWithPersist()
Workflow -> DB: workflow_runs.Create(albumID, quality)
Workflow -> DB: album_events.Create(CHECKING_OWNED)
Workflow -> Bus: Publish(CHECKING_OWNED)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> Indexer: Search()
Workflow -> DB: album_events.Create(PARSING_RESULTS)
Workflow -> Bus: Publish(PARSING_RESULTS)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(PARSING_RESULTS)
Workflow -> Workflow: filterByQuality + selectBest
Workflow -> DB: saveTorrentAndDownload()
note right: DB save BEFORE qBit add\n(prevents orphan torrents)
Workflow -> QBit: AddMagnet()
Workflow -> DB: album_events.Create(COMPLETE)
Workflow -> Bus: Publish(COMPLETE)
Bus ->> Server: event notification
Server ->> Client: MonitorAlbumResponse
note right #lightgreen: Final result
Workflow -> DB: workflow_runs.SetCompleted()
Workflow -> Registry: Remove(albumID, quality)
== 4. Client Disconnect (Fire-and-Forget) ==
note over Client, Workflow #lightyellow
Client can disconnect at ANY point during the workflow.
The workflow goroutine continues independently.
Another client can subscribe to the same workflow later.
end note
@enduml
@@ -0,0 +1,157 @@
@startuml MonitorAlbumStream - Manual Mode Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Manual Mode (Interactive Prompts)
actor Client
participant "gRPC Server" as Server
participant "monitorWorkflow" as Workflow
participant "MusicAgregatorService" as Service
database "PostgreSQL" as DB
participant "IndexerService" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
== 1. Initialize Stream ==
Client -> Server: MonitorAlbumStream()
Server -> Client: stream established
Client -> Server: StartMonitorRequest
note right: album_id, quality\nmode = MANUAL
Server -> Workflow: newMonitorWorkflow()
== 2. Fetch Metadata ==
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Fetching album metadata..."
Workflow -> Service: getAlbumWithPersist()
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: Data: StreamAlbumInfo\n{artist, title, release_date,\nalready_owned, owned_quality}
== 3. Check Ownership (Interactive) ==
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true (already owned!)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
note right #lightyellow: "Already owned in FLAC quality"
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false\ntimeout: max 300s
Client -> Server: UserDecision
note right: confirm: true\n(user chooses to continue)
Workflow -> Workflow: Continue with search
== 4. Search & Parse ==
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
Workflow -> Indexer: Search()
Indexer --> Workflow: 3 results
loop parse results
Workflow -> Magnet: Resolve()
end
Workflow ->> Client: StatusUpdate(PARSING_RESULTS)
note right #lightblue: Data: TorrentList\n[{id, title, seeders, format}, ...]
== 5. Select Torrents (Interactive) ==
Workflow ->> Client: PromptForDecision
note right #orange: type: SELECT_MANY\nmessage: "Select torrents to consider"\noptions: [{id, label, description}, ...]\ndefault: all selected\nmin: 1, max: N
Client -> Server: UserDecision
note right: selected_ids: ["torrent-0", "torrent-2"]\n(user deselects torrent-1)
Workflow -> Workflow: Filter to selected torrents
== 6. Filter by Quality ==
Workflow ->> Client: StatusUpdate(FILTERING_QUALITY)
Workflow -> Workflow: filterByQuality()
note right: 2 releases remain\nafter quality filter
== 7. Select Release (Interactive) ==
Workflow ->> Client: PromptForDecision
note right #orange: type: SELECT_ONE\nmessage: "Select release"\noptions: [{id, label, description}, ...]\ndefault: highest seeders
Client -> Server: UserDecision
note right: selected_id: "release-1"\n(user picks specific release)
Workflow ->> Client: StatusUpdate(SELECTING_RELEASE)
note right #lightblue: Data: ReleaseInfo\n{hash, format, seeders, tracker}
== 8. Confirm Add (Interactive) ==
Workflow ->> Client: StatusUpdate(ADDING_TORRENT)
note right #lightyellow: "Adding torrent: Title..."
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Add torrent 'Title' to client?"\nconfirm_label: "Add"\ncancel_label: "Skip"\ndefault: true
Client -> Server: UserDecision
note right: confirm: true
== 9. Add & Save ==
Workflow -> QBit: AddMagnet()
QBit --> Workflow: OK
Workflow ->> Client: StatusUpdate(SAVING)
Workflow -> DB: Create torrent & download
== 10. Complete ==
Workflow ->> Client: StatusUpdate(COMPLETE)
note right #lightblue: "Download started"
Workflow ->> Client: MonitorAlbumResponse
note right #lightgreen: Final result
== Cancel Cleanup (Disconnect or Cancel Message) ==
note over Client, QBit #salmon
**Manual Mode: Disconnect = Cancel**
When client disconnects or sends CancelRequest:
1. Workflow context is cancelled (stops further processing)
2. If torrent was added to qBit: **DeleteTorrent(hash)** removes it + files
3. If download record exists: marked as **cancelled** in DB
4. workflow_run marked as **cancelled** in DB
5. All events persisted to album_events for audit trail
Cleanup uses a fresh context (not the cancelled one).
end note
== Decision Points Summary ==
note over Client, QBit #lightyellow
**Manual Mode Decision Points:**
1. **CHECKING_OWNED** (CONFIRM)
- Triggered when: Album already owned in requested quality
- Default: false (skip)
- Timeout action: Use default
2. **PARSING_RESULTS** (SELECT_MANY)
- Triggered when: Multiple torrents found (>1)
- Default: All selected
- Timeout action: Use defaults
3. **SELECTING_RELEASE** (SELECT_ONE)
- Triggered when: Multiple releases after quality filter (>1)
- Default: Highest seeders
- Timeout action: Use default
4. **ADDING_TORRENT** (CONFIRM)
- Triggered: Always in manual mode
- Default: true (add)
- Timeout action: Use default
end note
@enduml
@@ -0,0 +1,144 @@
@startuml MonitorAlbumStream Protocol
skinparam sequenceMessageAlign center
title MonitorAlbumStream & SubscribeEvents: Message Protocol
participant "Client" as C
participant "Server" as S
== MonitorAlbumStream (Bidirectional) ==
C -> S: gRPC MonitorAlbumStream()
note right: Opens bidirectional stream
== Request Messages (Client -> Server) ==
C -> S: MonitorAlbumStreamRequest
note right #lightblue
**oneof message:**
- **start**: StartMonitorRequest
- album_id (required)
- quality: LOSSLESS | LOSSY | UNSPECIFIED
- mode: AUTOMATIC (0) | MANUAL (1)
- indexer_options: {tracker}
- **decision**: UserDecision
- prompt_id (must match pending prompt)
- confirm | selected_id | selected_ids
- **cancel**: CancelRequest
- Gracefully terminates workflow
end note
== Response Messages (Server -> Client) ==
S -> C: MonitorAlbumStreamResponse
note left #lightgreen
**oneof message:**
- **status**: StatusUpdate
- step: MonitorStep enum
- message: human-readable text
- data: StreamAlbumInfo | TorrentList | ReleaseInfo
- **prompt**: PromptForDecision
- prompt_id: unique identifier
- type: CONFIRM | SELECT_ONE | SELECT_MANY
- message: prompt text
- timeout_seconds: response deadline
- options: confirm | select_one | select_many config
- **result**: MonitorAlbumResponse
- Final response (stream ends after this)
- **error**: ErrorUpdate
- failed_step: where error occurred
- message: error description
- recoverable: bool
end note
== SubscribeEvents (Server-Side Stream) ==
C -> S: SubscribeEvents(SubscribeEventsRequest)
note right #lightyellow
**SubscribeEventsRequest:**
- since_seq: int64 (0 = live only, >0 = replay from seq)
**Response stream: AlbumEvent**
- seq: int64 (monotonic sequence number)
- workflow_run_id, album_id, quality
- event_type: status | error | result
- step: MonitorStep name
- message: human-readable text
- data_json: bytes (optional structured data)
- timestamp_ms: int64
**Global firehose**: receives events from ALL running workflows.
Client-side filtering by album_id if needed.
end note
== Event Flow Architecture ==
note over C, S #lightblue
**Event Path (DB first, bus second):**
1. Workflow step executes
2. Event written to **album_events** table (synchronous, durable)
3. Event published to **EventBus** (async, ephemeral notification)
4. Bus fans out to subscribers:
- MonitorAlbumStream clients (per-topic)
- SubscribeEvents clients (global)
5. Subscribers convert event to proto and stream.Send()
**DB is source of truth. Bus is notification layer.**
Events are never lost, even if no subscribers are connected.
end note
== Monitor Steps ==
note over C, S #lightyellow
**MonitorStep Enum:**
1. FETCHING_METADATA - Getting album info from metadata service
2. CHECKING_OWNED - Checking if already downloaded
3. SEARCHING_INDEXER - Querying Jackett/indexers
4. PARSING_RESULTS - Resolving magnets, parsing torrents
5. FILTERING_QUALITY - Applying quality filter
6. SELECTING_RELEASE - Choosing best/user-selected release
7. ADDING_TORRENT - Adding to qBittorrent
8. SAVING - Persisting to database
9. COMPLETE - Workflow finished
end note
== Mode Comparison ==
note over C, S #lightyellow
**AUTOMATIC mode:**
- Workflow runs as background goroutine
- Client can disconnect, workflow continues (fire-and-forget)
- Duplicate request for same album+quality subscribes to existing
- Events delivered via EventBus bridge
**MANUAL mode:**
- Workflow runs inline in stream handler
- Interactive prompts at 4 decision points
- Disconnect = cancel = full cleanup (qBit delete + DB cancel)
- Events delivered directly via stream + persisted to DB
end note
== Persistence ==
note over C, S #lightgreen
**workflow_runs table:**
- Tracks workflow lifecycle: running → completed | failed | cancelled
- Unique constraint: one running workflow per album+quality
- Used for deduplication, recovery, and audit
**album_events table:**
- Audit log of all workflow events
- seq BIGSERIAL for ordering and replay
- Supports subscribe-before-query replay pattern
end note
== Timeout Behavior ==
note over C, S #pink
When prompt times out (max: 300s):
- Server uses **default decision** value
- Workflow continues automatically
- No error is raised
end note
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

@@ -0,0 +1,193 @@
@startuml Release Parsing Decision Tree
skinparam ActivityBackgroundColor #f8f8f8
skinparam ActivityBorderColor #333333
skinparam DiamondBackgroundColor #fffde7
skinparam NoteBackgroundColor #e3f2fd
title Release Parsing Decision Tree
start
partition "1. Resolve Torrent Data" {
if (DownloadLink starts\nwith "magnet:?") then (yes)
:MagnetResolver.Resolve(magnetURI);
note right
DHT lookup via anacrolix/torrent
30s timeout, 15s early exit
if peers but none active
end note
if (Resolve succeeded?) then (yes)
:torrentData = resolved bytes;
else (no)
:fallback to **title-only parse**;
note right
parser.Parse(item.Title)
No torrent data available
No info_hash computed
end note
goto TitleOnlyParse
endif
else (HTTP link)
:downloadTorrentData(url);
note right
HTTP GET with 30s timeout
Expects .torrent file bytes
end note
if (Download succeeded?) then (yes)
:torrentData = downloaded bytes;
else (no)
:fallback to **title-only parse**;
goto TitleOnlyParse
endif
endif
}
partition "2. ParseTorrent (torrentData + metadata album)" {
partition "2a. Fill from Metadata Album" {
:Artist = album.Artists[0].Name;
:Album = album.Title;
:Year = album.ReleaseDate[:4];
:Type = album.AlbumType
(album/ep/single/compilation/...);
:Genres = album.Genres[].Name;
:Label = album.Label.Name;
:TrackCount = album.TotalTracks;
:ReleaseCount = album.TotalDiscs;
}
partition "2b. Fill from Torrent Data" {
:metainfo.Load(torrentData);
note right
Bencode decode via
anacrolix/torrent/metainfo
end note
if (Parse failed?) then (yes)
:Append to ParseErrors;
:Skip torrent analysis;
else (no)
:info = mi.UnmarshalInfo();
if (Unmarshal failed?) then (yes)
:Append to ParseErrors;
:Skip torrent analysis;
else (no)
:RawTitle = info.Name;
:InfoHash = SHA1(info dict);
if (info.Files is empty?\n(single-file torrent)) then (yes)
:ext = filepath.Ext(info.Name);
if (ext is audio?\n(.flac/.mp3/.aac/...)) then (yes)
:Format = audioExtensions[ext];
:AudioFileCount = 1;
:TotalAudioSize = info.Length;
else (no)
:Format = unknown;
endif
else (multi-file torrent)
:Iterate all files in torrent;
repeat
:file = next torrent file;
:ext = filepath.Ext(file.Path);
if (ext is audio?) then (yes)
:formatCounts[ext]++;
:formatSizes[ext] += file.Length;
:TrackNames += cleanTrackName(file);
note right
Strip leading "01. " or "1 - "
from filename
end note
elseif (ext is .jpg/.jpeg/.png?) then (yes)
:HasCoverArt = true;
elseif (ext is .cue?) then (yes)
:HasCueSheet = true;
elseif (ext is .log?) then (yes)
:HasRipLog = true;
endif
repeat while (more files?)
:Format = dominant format\n(most audio files);
:AudioFileCount = count of dominant;
:TotalAudioSize = sum of dominant;
endif
if (HasRipLog?) then (yes)
:Source = CD;
note right
.log file = EAC/XLD rip log
implies CD source
end note
endif
if (TrackCount == 0?) then (yes)
:TrackCount = AudioFileCount;
endif
endif
endif
}
partition "2c. Fill from Title (torrent name)" #f0f0f0 {
label TitleParsing
:title = info.Name (or item.Title for fallback);
if (title matches\n"(\\d{2,3})\\s*kbps"?) then (yes)
:Bitrate = matched value + " kbps";
endif
:Try hi-res patterns (in order):;
note right
1. "24Bit-96kHz" / "24 Bit / 48 kHz"
2. "FLAC 24-96" / "Flac 24-44"
3. "24Bit" (bit depth only)
First match wins.
end note
if (Hi-res pattern matched?) then (yes)
if (BitDepth still 0?) then (yes)
:BitDepth = matched group 1;
endif
if (SampleRate still 0\nand group 2 exists?) then (yes)
:SampleRate = matched × 1000;
endif
endif
if (title matches\n"\\[(CD|WEB|Vinyl|...)\\]"\nand Source still unknown?) then (yes)
:Source = matched value;
note right
CD, WEB, Vinyl/LP,
Cassette/MC, DVD,
Blu-Ray
end note
endif
if (title matches\nrip type pattern?) then (yes)
:RipType = matched value;
note right
vinyl rip, SACD-R,
HDCD, DSD, tape rip
end note
endif
}
:ParsedSuccessfully = (Artist != "" && Album != "");
if (not ParsedSuccessfully?) then (yes)
:ParseErrors += "missing artist or album";
endif
:Return Release;
stop
}
partition "3. Title-Only Parse (fallback)" #fff3e0 {
label TitleOnlyParse
:r = Release{RawTitle: item.Title};
note right
No torrent data available.
No InfoHash. No file analysis.
No TrackNames. No cover/cue/log.
Format stays **unknown**.
end note
goto TitleParsing
}
@enduml
+114 -208
View File
@@ -16,259 +16,165 @@ skinparam package {
title Music Aggregator - Database Structure
' ══════════════════════════════════════════════════════════════
' CORE MUSIC ENTITIES
' ══════════════════════════════════════════════════════════════
package "Core Music Entities" #E3F2FD {
entity "artist_metadata" {
package "Music Metadata" #E3F2FD {
entity "artists" {
* id : UUID <<PK>>
--
foreign_artist_id : TEXT <<UNIQUE>>
name : TEXT
sort_name : TEXT
disambiguation : TEXT
artist_type : TEXT
status : TEXT
overview : TEXT
images : JSONB
links : JSONB
genres : JSONB
external_id : VARCHAR(255) <<UNIQUE>>
name : VARCHAR(500)
artist_type : VARCHAR(50)
country : VARCHAR(10)
genres : TEXT[]
image_url : TEXT
--
created_at : TIMESTAMPTZ
updated_at : TIMESTAMPTZ
}
entity "artists" {
* id : UUID <<PK>>
--
metadata_id : UUID <<FK>>
quality_profile_id : UUID <<FK>>
metadata_profile_id : UUID <<FK>>
root_folder_id : UUID <<FK>>
--
path : TEXT
monitored : BOOLEAN
monitor_new_items : TEXT
--
last_info_sync : TIMESTAMPTZ
added_at : TIMESTAMPTZ
}
entity "albums" {
* id : UUID <<PK>>
--
artist_metadata_id : UUID <<FK>>
external_id : VARCHAR(255) <<UNIQUE>>
artist_id : UUID <<FK>>
--
foreign_album_id : TEXT <<UNIQUE>>
title : TEXT
clean_title : TEXT
disambiguation : TEXT
overview : TEXT
album_type : TEXT
title : VARCHAR(500)
album_type : VARCHAR(50)
release_date : DATE
images : JSONB
genres : JSONB
total_tracks : INT
total_discs : INT
label : VARCHAR(255)
genres : TEXT[]
cover_url : TEXT
is_monitored : BOOLEAN
--
monitored : BOOLEAN
added_at : TIMESTAMPTZ
}
entity "album_releases" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>>
--
foreign_release_id : TEXT <<UNIQUE>>
title : TEXT
status : TEXT
duration_ms : INT
release_date : DATE
country : TEXT[]
label : TEXT[]
format : TEXT
track_count : INT
--
monitored : BOOLEAN
created_at : TIMESTAMPTZ
updated_at : TIMESTAMPTZ
}
entity "tracks" {
* id : UUID <<PK>>
--
album_release_id : UUID <<FK>>
artist_metadata_id : UUID <<FK>>
track_file_id : UUID <<FK NULL>>
external_id : VARCHAR(255) <<UNIQUE>>
album_id : UUID <<FK>>
--
foreign_track_id : TEXT <<UNIQUE>>
title : TEXT
track_number : INT
disc_number : INT
title : VARCHAR(500)
duration_ms : INT
explicit : BOOLEAN
}
entity "track_files" {
* id : UUID <<PK>>
isrc : VARCHAR(20)
disc_number : INT
track_number : INT
--
album_id : UUID <<FK>>
--
path : TEXT
relative_path : TEXT
size : BIGINT
--
file_hash : TEXT
audio_hash : TEXT
--
quality : JSONB
media_info : JSONB
--
scene_name : TEXT
release_group : TEXT
--
date_added : TIMESTAMPTZ
created_at : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' CONFIGURATION
' ══════════════════════════════════════════════════════════════
package "Configuration" #FFF3E0 {
entity "quality_profiles" {
package "Torrent Catalog" #FFF3E0 {
entity "torrents" {
* id : UUID <<PK>>
--
name : TEXT <<UNIQUE>>
cutoff : INT
items : JSONB
upgrade_allowed : BOOLEAN
}
entity "metadata_profiles" {
* id : UUID <<PK>>
--
name : TEXT <<UNIQUE>>
primary_album_types : JSONB
secondary_album_types : JSONB
release_statuses : JSONB
}
entity "root_folders" {
* id : UUID <<PK>>
--
name : TEXT
path : TEXT <<UNIQUE>>
default_quality_profile_id : UUID <<FK>>
default_metadata_profile_id : UUID <<FK>>
}
entity "indexers" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
enable_rss : BOOLEAN
enable_search : BOOLEAN
priority : INT
}
entity "download_clients" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
protocol : TEXT
priority : INT
enabled : BOOLEAN
}
}
' ══════════════════════════════════════════════════════════════
' DOWNLOAD TRACKING
' ══════════════════════════════════════════════════════════════
package "Download Tracking" #E8F5E9 {
entity "wanted_albums" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>> <<UNIQUE>>
--
priority : INT
search_count : INT
last_searched_at : TIMESTAMPTZ
added_at : TIMESTAMPTZ
}
entity "download_queue" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
info_hash : VARCHAR(40) <<UNIQUE>>
--
download_id : TEXT
tracker : VARCHAR(100)
title : TEXT
format : VARCHAR(20)
quality : VARCHAR(20)
source : VARCHAR(20)
bit_depth : INT
sample_rate : INT
seeders : INT
peers : INT
size : BIGINT
size_left : BIGINT
track_count : INT
has_cover_art : BOOLEAN
has_cue_sheet : BOOLEAN
has_rip_log : BOOLEAN
download_link : TEXT
torrent_file : BYTEA
--
status : TEXT
progress : REAL
created_at : TIMESTAMPTZ
updated_at : TIMESTAMPTZ
}
}
package "Download Management" #E8F5E9 {
entity "downloads" {
* id : UUID <<PK>>
--
torrent_id : UUID <<FK>>
album_id : UUID
format : VARCHAR(20)
quality : VARCHAR(20)
--
state : download_state
qbit_hash : VARCHAR(64)
save_path : TEXT
error_message : TEXT
--
protocol : TEXT
indexer : TEXT
download_client : TEXT
torrent_hash : TEXT
output_path : TEXT
--
added_at : TIMESTAMPTZ
queued_at : TIMESTAMPTZ
started_at : TIMESTAMPTZ
completed_at : TIMESTAMPTZ
created_at : TIMESTAMPTZ
updated_at : TIMESTAMPTZ
}
entity "blocklist" {
entity "download_files" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
download_id : UUID <<FK>>
track_id : UUID <<FK NULL>>
--
source_title : TEXT
quality : JSONB
size : BIGINT
protocol : TEXT
indexer : TEXT
message : TEXT
torrent_hash : TEXT
file_path : TEXT
file_size : BIGINT
file_type : VARCHAR(20)
sha256_hash : VARCHAR(64)
--
date : TIMESTAMPTZ
verified_at : TIMESTAMPTZ
created_at : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' RELATIONSHIPS
' ══════════════════════════════════════════════════════════════
package "Caching & Queue (River)" #F3E5F5 {
entity "river_job" {
* id : BIGSERIAL <<PK>>
--
kind : TEXT
state : river_job_state
queue : TEXT
args : JSONB
metadata : JSONB
--
attempt : SMALLINT
max_attempts : SMALLINT
priority : SMALLINT
--
scheduled_at : TIMESTAMPTZ
attempted_at : TIMESTAMPTZ
created_at : TIMESTAMPTZ
finalized_at : TIMESTAMPTZ
}
' Core music relationships
artist_metadata ||--|| artists : "has config"
artist_metadata ||--o{ albums : "released"
albums ||--o{ album_releases : "has releases"
album_releases ||--o{ tracks : "contains"
tracks }o--o| track_files : "stored in"
track_files }o--|| albums : "belongs to"
entity "river_queue" {
* name : TEXT <<PK>>
--
metadata : JSONB
paused_at : TIMESTAMPTZ
created_at : TIMESTAMPTZ
updated_at : TIMESTAMPTZ
}
}
' Artist config relationships
artists }o--|| quality_profiles : "uses"
artists }o--o| metadata_profiles : "uses"
artists }o--o| root_folders : "stored in"
note right of river_job
Cache refresh jobs:
kind = "indexer_cache_refresh"
args = {key, url, ttl_expires, refresh_interval}
scheduled_at = next refresh time
end note
' Root folder defaults
root_folders }o--o| quality_profiles : "default"
root_folders }o--o| metadata_profiles : "default"
' Download tracking relationships
wanted_albums ||--|| albums : "targets"
download_queue }o--o| artists : "for"
download_queue }o--o| albums : "for"
blocklist }o--|| artists : "for"
blocklist }o--o| albums : "for"
artists ||--o{ albums : "released"
albums ||--o{ tracks : "contains"
albums ||--o{ torrents : "available on"
torrents ||--o| downloads : "downloaded as"
downloads ||--o{ download_files : "consists of"
tracks ||--o| download_files : "matched to"
@enduml
+224
View File
@@ -0,0 +1,224 @@
# Общие требования к раздачам в разделе КЛАССИЧЕСКОЙ музыки RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=773016
Для разделов: lossy, lossless, видео, DVD, Hi-Res, оцифровки, классика в современной обработке.
---
## 1. Общие положения
Данные правила дополняют общие правила музыкальных разделов и применяются ко всем подразделам классической музыки.
## 2. Определение повторов
### 2.1 Проверка перед созданием
Обязательно использовать поиск по трекеру.
### 2.2 Что считается повтором
Материал, не отличающийся в лучшую сторону по качеству.
### 2.3 Что НЕ считается повтором
- lossy при наличии lossless и наоборот
- Rip при наличии DVD и наоборот
- **Одно произведение с разным составом исполнителей**
- **Одно произведение с разной датой записи**
- Одна запись разных лейблов (при существенных отличиях ремастеринга)
### 2.4 Перекрёстные раздачи
🚫 Запрещены.
## 3. Требования к наполнению
### 3.1 Одна раздача = одно издание
🚫 Запрещено помещать несколько официальных изданий в одну lossless-раздачу.
### Многодисковые издания:
- **Box-set** - диски в одной коробке
- **Серия** - с явным указанием принадлежности на обложке/сайте
### 3.2 Исключения
#### 3.2.1 Дискографии исполнителей
Отдельные альбомы популярных академических исполнителей.
> **Альбом** - набор композиций, подобранный специально для издания.
> **Не альбом** - запись отдельного произведения (например, "Риголетто").
#### 3.2.2 Полные циклы
- Все симфонии композитора
- Все концерты
- Все квартеты
- Циклы по замыслу автора ("Кольцо нибелунга", "Времена года")
При одинаковом исполнительском составе.
### 3.3 Сборники
🚫 Запрещены сборники без чёткой темы или с разнородным содержанием.
### 3.4 Минимум треков
🚫 Один трек запрещён (кроме самодостаточных произведений).
### 3.5 Полнота
🚫 Запрещены неполные рипы или разделение диска.
### 3.6 Целостность
🚫 Запрещено разделять произведение на несколько раздач.
### 3.7 Качество записи
🚫 Запрещены записи с телефона/диктофона.
### 3.8 Битрейт
🚫 MP3 < 192 kbps
🚫 Срез частот < 16 кГц
🚫 Частота дискретизации < 44 кГц
✅ Исключение: редкие/раритетные записи (решение модератора).
## 4. Требования к заголовкам
### 4.1 Обязательное содержание
```
Композитор - Название произведения (Исполнитель)
```
### 4.2 Сборники по композитору
```
Композитор - Название сборника, Произведения (Исполнители)
```
### 4.3 Сборники по исполнителю
```
Название сборника - Композиторы, Произведения (Исполнитель)
```
### 4.4 Прочие сборники
```
Название сборника - Композиторы, Произведения
```
### 4.5 Разделитель
Тире между композитором и произведением.
### 4.6 Язык
Язык оригинала (родной язык композитора или авторское название).
### 4.7 Диакритика
Дублировать латиницей при умляутах.
### 4.8 Перевод
Рекомендуется русский перевод через `/`.
### Пример:
```
(Classical, Opera) Rossini - Il barbiere di Siviglia / Россини - Севильский цирюльник (B. Sills, Н. Гедда, LSO, James Levine) - 1975, APE (image+.cue) lossless
```
### 4.9 Запреты
🚫 Сокращения (кроме № и #)
🚫 CAPS LOCK
🚫 Точки (кроме пунктуации)
## 5. Жанры классической музыки
### Обязательный тег
**Classical** - для всех раздач
### Вокальное искусство:
- **Opera** - опера
- **Choral** - хоровая музыка
- **Vocal** - произведения для голоса
### Оркестровая музыка:
- **Symphony** - симфонии
- **Concerto** - концерты для солиста с оркестром
- **Orchestral** - увертюры, сюиты, поэмы, балеты
### Камерная музыка:
- **Chamber** - сонаты, дуэты, трио, квартеты
### Сольная музыка:
- **Piano** - фортепиано
- **Organ** - орган
- **Violin** - скрипка
- **Cello** - виолончель
- **Guitar** - классическая гитара
- **Harp** - арфа
- **Flute** - флейта
### Эпохи:
- **Medieval** - XIXIV века
- **Renaissance** - 14001600
- **Baroque** - 16001750
- **Romantic** - XIX–нач. XX века
- **Avantgarde** - нач.–сер. XX века
- **Minimalism** - с 1964 года
### Видео:
- **Ballet** - классический балет
- **Dance** - современный балет
- **Concert** - концерты
- **Documentary** - фильмы, мастер-классы
## 6. Треклист и исполнители
### 6.1 Обязательность
Треклист и список исполнителей **строго обязательны**.
### 6.2 Соответствие
Должны точно соответствовать содержанию.
### 6.3 Содержание треклиста
- Фамилия композитора
- Название произведения
### 6.4 Формат исполнителей
```
Солисты (партии), Хор, Оркестр/Ансамбль, Дирижёр
```
### 6.5 Крупные формы
Для опер, симфоний - допускается указание диапазона треков:
```
1-4. Chopin: Piano Sonata No.2
```
### 6.6 Фрагменты
Указывать полное название + название фрагмента:
```
01. Nessun dorma! - Turandot (Puccini)
```
### 6.7 Дополнительно
- Лейбл
- Дата и место записи
- Продолжительность (чч:мм:сс)
## Паттерны для парсера
```regex
# Opus номер
Op\.\s*(\d+)(?:\s*[Nn]o\.\s*(\d+))?
# BWV/KV/D номера
(?:BWV|KV|K\.|D\.|Op\.)\s*(\d+)
# Формат названия произведения
(Symphony|Concerto|Sonata|Quartet|Suite|Overture)\s*(?:No\.\s*)?(\d+)
# Тональность
in\s+([A-G])\s*(major|minor|[-♯♭]?\s*(?:dur|moll))?
# Исполнитель с оркестром
([^,]+),\s*([^,]+(?:Orchestra|Philharmonic|Symphony))[,;]\s*([^)]+)
# Композитор - Произведение
^([A-Za-zА-Яа-яёÄÖÜäöüß\s]+)\s*[-]\s*(.+)$
```
## История изменений
- **28.07.2014** - п. 6.5.1
- **17.03.2016** - пп. 3.1.1, 3.1.2, добавлен 3.9
- **11.07.2022** - пп. 4.2.2, 4.2.3
- **07.06.2023** - пп. 4.1, 4.6, 5.2; объединены 4.7-4.9
+125
View File
@@ -0,0 +1,125 @@
# Разъяснения по дискографиям и коллекциям RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372771
## Определения
### Дискография (Discography)
- Содержит слово "Дискография" или "Discography"
- Включает релизы только под **ОДНИМ** именем исполнителя
- Формат количества: `(15 CD)`, `(20 albums)`, `(10 releases)`
- Формат годов: `[19902020]` (используется длинное тире)
### Коллекция (Collection)
- Содержит слово "Коллекция" или "Collection"
- Может включать **несколько** имён/псевдонимов одного артиста
- Включает сайд-проекты, сольные работы, коллаборации
- Тот же формат количества и годов
## Ключевые различия
| Критерий | Дискография | Коллекция |
|----------|-------------|-----------|
| Имена артиста | Только одно | Несколько разрешено |
| Псевдонимы | Не включены | Включены |
| Сайд-проекты | Отдельно | Включены |
| Коллаборации | Отдельно | Включены |
## Формат заголовка
### Дискография:
```
[Artist] - Дискография (15 CD) [19902020, Rock, MP3, 320 kbps]
[Artist] - Discography (30 releases) [19852023, Electronic, FLAC]
```
### Коллекция:
```
[Artist] - Коллекция (50 CD) [19802019, Various, FLAC]
[Artist] - Collection (100 albums) [19752025, Rock, MP3, VBR]
```
## Правила сайд-проектов
### Когда включать в коллекцию:
- Артист "определяет лицо" коллектива
- Артист - основной автор/вокалист
- Проект широко ассоциируется с артистом
### Когда выделять отдельно:
- Равноправное участие нескольких артистов
- Артист - приглашённый участник
- Проект имеет собственную идентичность
## Неофициальные релизы
🚫 **Запрещено** включать в дискографии/коллекции:
- Бутлеги
- Фан-компиляции
- Неофициальные сборники
- Самодельные ремастеры
**Разрешено**:
- Официальные альбомы
- Официальные синглы и EP
- Официальные компиляции
- Официальные переиздания
## Формат папок
```
Artist Name/
├── 1990 - Album One/
├── 1992 - Album Two/
├── 1995 - Album Three (Remaster 2010)/
└── 2020 - Latest Album/
```
## Требования к оформлению
### Обязательно для каждого релиза:
- Название альбома
- Год выпуска
- Каталожный номер (если есть)
- Жанр
- Треклист
- Продолжительность
### Под спойлером для каждого альбома:
```bbcode
[spoiler="Album Name (Year)"]
Каталог: LABEL001
Жанр: Rock
Продолжительность: 45:30
Треклист:
01. Track One (4:30)
02. Track Two (5:15)
...
[/spoiler]
```
## Поглощение
- Дискография поглощает отдельные альбомы того же качества
- Коллекция поглощает дискографию + сайд-проекты
- Более качественная версия поглощает менее качественную
## Паттерны для парсера
```regex
# Дискография
[Дд]искографи[яи]|[Dd]iscograph(?:y|ies)
# Коллекция
[Кк]оллекци[яи]|[Cc]ollection
# Количество релизов
\((\d+)\s*(?:CD|albums?|releases?|релиз(?:а|ов)?|альбом(?:а|ов)?)\)
# Диапазон годов (с длинным тире)
\[(\d{4})[-](\d{4})
# Жанр в скобках
,\s*([A-Za-z\s,/]+),
```
+273
View File
@@ -0,0 +1,273 @@
# Общие правила публикации и оформления раздач RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372776
**Версия:** © 2023
**Последнее обновление:** 08-Дек-25
Действует для категорий: Музыка, Популярная музыка, Джазовая и Блюзовая музыка, Рок-музыка, Электронная музыка, Hi-Res форматы, оцифровки.
---
## 1. Введение
### 1.1 Связанные правила
- [Правила для lossless](https://rutracker.org/forum/viewtopic.php?t=6372775)
- [Правила для lossy (MP3)](https://rutracker.org/forum/viewtopic.php?t=6372774)
- [Правила для Hi-Res](https://rutracker.org/forum/viewtopic.php?t=5093969)
- [Правила оцифровок](https://rutracker.org/forum/viewtopic.php?t=3558517)
---
## 2. Подготовка к созданию раздачи
### 2.1 Проверка жанра и формата
Убедитесь в соответствии выбранному подразделу.
### 2.2 Поиск повторов
**Обязательно** проверить через поиск по трекеру.
Повтор - материал, не отличающийся в лучшую сторону по качеству.
**Не считается повтором:**
- lossy при наличии lossless и наоборот
- Различные варианты мастеринга одного альбома
### 2.3 Запрещённые раздачи
- 🚫 Сборники собственного составления
- 🚫 WEB-сборники для стриминга (Deezer, Spotify)
- 🚫 Единичные треки, не изданные официально
- ✅ Любительские ремастеринги - в соответствующем подразделе
- ✅ AI-музыка - с дополнительными требованиями
### 2.4 Немузыкальный материал
🚫 Интервью - только в подразделе Аудио.
### 2.5 Смешение форматов
🚫 Запрещено объединять lossless и lossy (кроме официальных изданий).
### 2.6 Смешение Hi-Res и стандартного качества
🚫 Запрещено объединять Red Book с Hi-Res и оцифровками.
### 2.7 Неполные релизы
🚫 Запрещены неполные релизы и отдельные части многодисковых изданий.
### 2.8 Транскоды (апконверты)
🚫 Запрещены:
1. lossy → lossless
2. lossy → другой lossy кодек
3. Перекодирование с изменением битрейта
4. lossless низкого качества → lossless высокого (16/44.1 → 24/96)
---
## 3. Требования к содержимому
### 3.1 Запрещённые форматы образов
🚫 *.mdf/*.mds, *.iso (кроме iso.wv), *.nrg, *.bin+*.cue
🚫 Архивы (*.rar, *.zip)
### 3.2 Запрещённые файлы
🚫 *.exe, *.com, *.nfo, *.sfv
🚫 Файлы с рекламой сторонних ресурсов
✅ Плейлисты *.m3u, *.m3u8
### 3.3 Название папки
**Единичный релиз:**
```
Артист - Год - Альбом
Артист - Альбом - (Год)
```
❌ Неправильно: "Артист - Альбом", "ГодАльбом"
**Дискография:**
```
Артист/
├── Год - Альбом/
├── Год - Альбом/
```
### 3.4 Название файлов
**Альбом одного исполнителя:**
```
01 - Название
01. Название
A1 - Название (для винила)
```
❌ Неправильно: "Название - 01", "01Название"
**Сборник разных исполнителей:**
```
01. Исполнитель - Название
01 - Исполнитель - Название
```
**Нумерация:**
- 10+ треков: 01, 02 ... 09, 10
- 100+ треков: 001, 002 ... 099, 100
### 3.5 Теги
**Обязательные для статуса "проверено":**
- Артист
- Номер трека
- Название песни
- Альбом
- Год
**Для image+.cue:**
- REM DATE
- TITLE
- PERFORMER
- TRACK
Рекомендуемая кодировка: **UTF-8**
### 3.6 Соответствие треклисту
❌ "Track01", "Дорожка 1", "Unknown Artist"
✅ Исключение: промо-диски и миксы без треклиста
### 3.7 Транслитерация
🚫 Запрещена для кириллицы
✅ Допускается для иероглифов
### 3.8 Длина пути
Рекомендуется лаконичное именование (~255 символов ограничение ОС).
**Для image+.cue:**
```
CD.flac, CD.cue, CD.log
```
**Для tracks:**
```
02. What Have I Done ... Pt. 1.flac
```
(полное название в теге)
---
## 4. Требования к оформлению
### 4.1 Шаблон
Строго заполнять обязательные поля.
### 4.2 Форматирование
🚫 Нижние подчёркивания, точки (кроме пунктуации)
🚫 CAPS LOCK (кроме оригинального названия)
🚫 Нестандартные шрифты в технических полях
**Технические данные:**
```bbcode
[spoiler="Название"][pre]Текст[/pre][/spoiler]
```
### 4.3 Заголовок темы
Точное название на языке оригинала.
Дублировать латиницей при умляутах/диакритике.
### 4.4 Содержание заголовка
**Единичный релиз:**
- Исполнитель
- Год
- Название
- Формат
- Битрейт
**Сборная раздача:**
- Исполнитель
- Количество релизов
- Годы (1999-2010)
- Форматы (через запятую)
- Битрейт (диапазон: 128-320 kbps)
### 4.5 Треклист
**Обязателен.** Должен соответствовать содержанию.
### 4.6 Формат треклиста
🚫 Скриншоты
🚫 Содержание CUE-файлов
🚫 Обратная сторона обложки
### 4.7 Несколько релизов
Каждый под отдельный спойлер с:
- Техническими данными
- Треклистом
- Обложкой
### 4.8 Транслитерация треклиста
🚫 Для кириллицы
✅ Для иероглифов
### 4.9 Обложка
**Обязательна** (кроме релизов без обложки).
**Требования:**
- Размер: 200x200 - 600x600 px (макс. площадь 360000)
- Файл: до 600 KB
- 🚫 Анимация
- 🚫 Реклама
- Дополнительные обложки под спойлер
### 4.10 Хостинг изображений
Без регистрации и паролей.
Скриншоты спектров - масштабные превью.
### 4.11 Дополнительная информация
Более 8-10 строк - под спойлер.
### 4.12 Продолжительность
**Обязательно указывать** для каждого релиза.
### 4.13 Заголовок
🚫 "Обновлено 01.01.2022"
### 4.14 Денежные переводы
🚫 Номера счетов и кошельков.
### 4.15 Ссылки
🚫 Сторонние ресурсы
✅ Исключения:
- Официальный сайт исполнителя
- Официальный сайт лейбла
- Discogs и каталогизаторы
- Wikipedia
- YouTube
- WEB-магазины для WEB-релизов
### 4.16 Перезалив торрента
Обязательно сообщение о причинах.
---
## История изменений
- **24.11.2023** - пп. 3.4.1 и 4.9
- **03.01.2024** - п. 2.3
- **17.06.2024** - п. 3.7
- **17.01.2025** - пп. 2.3 и 4.2
- **08.12.2025** - п. 4.12
## Паттерны для парсера
```regex
# Стандартный заголовок
^(?:\([^)]+\)\s*)?(.*?)\s*-\s*(.*?)\s*-\s*(\d{4})
# Формат с годом
(\d{4})\s*-\s*(\d{4})|(\d{4})
# Битрейт
(\d+)\s*kbps|V[012]|lossless
# Формат
FLAC|APE|MP3|AAC|OGG|WV
# Тип рипа
image\+\.?cue|tracks\+\.?cue|tracks
```
+163
View File
@@ -0,0 +1,163 @@
# RuTracker Hi-Res Audio Upload Rules
**Source:** https://rutracker.org/forum/viewtopic.php?t=5093969
## 1. Допустимые форматы Hi-Res (Section 2.1.1)
### Стереорелизы из исходников высокого разрешения Hi-Res:
**PCM форматы:**
- **24(32)/192** (24 или 32 бит / 192 кГц)
- **24(32)/176.4** (24 или 32 бит / 176.4 кГц)
- **24(32)/96** (24 или 32 бит / 96 кГц)
- **24(32)/88.2** (24 или 32 бит / 88.2 кГц)
- **24(32)/48** (24 или 32 бит / 48 кГц)
- **24(32)/44.1** (не относится к Hi-Res, но допускается)
- **16/48** (не относится к Hi-Res, но допускается)
**DSD форматы (однобитные):**
- **DSD64** (1 bit / 2.8224 MHz) - базовый формат SACD
- **DSD128** (1 bit / 5.6 MHz) - в 128 раз выше 44.1 кГц
- **DSD256** (DSD265 в документе)
- **DSD512**
## 2. Правила оформления заголовка (Section 3.3-3.5)
### Для официальных Hi-Res релизов:
**Формат заголовка темы:**
```
[TR24][OF] Исполнитель - Название Альбома - Год (Жанр)
```
**Теги:**
- `[TR24]` - стереотреки повышенного и высокого разрешения (24 бит и выше)
- `[OF]` - официальный релиз (official)
### Для оцифровок с аналоговых носителей (винил, магнитные ленты):
**Формат заголовка:**
```
(Жанр) [Источник] [Формат] Исполнитель - Название Альбома - Год, Кодек (тип рипа)
```
**Примеры:**
```
(Hard Rock)[LP][24/96] Whitesnake - Slip Of The Tongue - 1989, WavPack (image+.cue)
(Pop) [LP] [24/96] Hi-Fi - Лучшее (2 LP) - 2015, FLAC (image+.cue)
(Score/Soundtrack) [LP] [24/96] Matt Uelmen 2022 "Diablo II: Resurrected", FLAC (image+.cue)
(Rock Opera) [LP] [24/192] Various (Shiki Theatrical Company) Jesus Christ Superstar (in Japanese) - 1976, FLAC (image+.cue)
```
## 3. Теги источников (Source Tags)
- `[LP]` - винил, пластинка диаметром 12 дюймов, записанная на 33-й скорости
- `[EP]` - Extended Play (мини-альбом)
- `[7"]` - сингл 7 дюймов
- `[2LP]` - двойной винил
- `[LP MONO]` - моно запись с винила
- `[DVDA]` - DVD-Audio формат
- `[SACD]` - Super Audio CD
- `[DSD]` - Direct Stream Digital формат
- `[HDAD]` - High Definition Audio Disc (DVDA с 24/192 или 24/176.4)
## 4. Формат Bit Depth / Sample Rate
**Обозначение в квадратных скобках:**
- `[24/96]` - 24 бит / 96 кГц
- `[24/192]` - 24 бит / 192 кГц
- `[24/48]` - 24 бит / 48 кГц
- `[24/88.2]` - 24 бит / 88.2 кГц
- `[24/176.4]` - 24 бит / 176.4 кГц
- `[16/48]` - 16 бит / 48 кГц
**Формат: [бит/кГц]**
## 5. DSD Notation (Section 2.1.1, 2.4.3)
**Форматы DSD:**
```
DSD64 (1 bit / 2.8224 MHz) - базовый SACD формат
DSD128 (1 bit / 5.6 MHz) - удвоенная частота
DSD256 (1 bit / 11.2 MHz)
DSD512 (1 bit / 22.4 MHz)
```
**Альтернативная запись:**
- `DST64` / `DST128` - lossless сжатие DSD (формат DST - MPEG-4 audio)
## 6. Название папки в раздаче (Section 3.4)
**Формат:**
```
Имя Исполнителя - Название Альбома - (Год - Разрядность-Частоту)
```
**Пример:**
```
Whitesnake - Slip Of The Tongue - (1989 - 24-96)
```
## 7. Обязательные требования к оформлению
### Dynamic Range Meter (DR meter) - Section 3.7:
Обязательно публиковать лог DR под спойлером:
```
[spoiler="Динамический диапазон (DR)"][pre]
Содержимое лога DR
[/pre][/spoiler]
```
### Спектры и графики (для оцифровок) - Section 3.3:
Обязательно показать:
- Не менее одного изображения спектра
- АЧХ (амплитудно-частотная характеристика)
- Уровня записи
## 8. Запрещенные форматы (Section 2.2)
1. **WAV-файлы (PCM Wave), не упакованные lossless-кодеками**
- Степень сжатия FLAC должна быть не менее 5 (рекомендуется 8)
- Для DSD форматом lossless-сжатия является DST (.dff) или WavPack (.wv)
2. **Релизы с разной частотой семплирования** (Section 2.5.1)
3. **Апконверты** (Section 5.2)
- Апконверты 16 > 24 бит и 44.1 > 48 кГц получают статус "# сомнительно"
## 9. Дополнительные теги
- `[RM]` - материал подвергался обработке (Remastered)
- `[restored]` - исправление технических недостатков
- `[declipped]` - удаление клиппинга
## Parser Patterns
```regex
# Official Hi-Res
\[TR24\]\[OF\]
# Vinyl/Analog Digitizations
\[LP\]\s*\[24/\d+\]
\[EP\]\s*\[24/\d+\]
# Bit depth / Sample rate
\[24/96\]
\[24/192\]
\[24/48\]
\[24/88\.2\]
\[24/176\.4\]
# DSD formats
DSD64|DSD128|DSD256|DSD512
DST64|DST128
\[DSD\]
```
## Additional References
- Теги для оцифровок: https://rutracker.org/forum/viewtopic.php?t=2672956
- Правила оцифровок: https://rutracker.org/forum/viewtopic.php?t=3558517
- Общие правила музыкальных категорий: https://rutracker.org/forum/viewtopic.php?t=6372776
+180
View File
@@ -0,0 +1,180 @@
# Интерактивное содержание правил музыкальных категорий RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=2973838
**Актуально на:** 11.06.2023
---
## Общие правила
| Документ | Topic ID | Локальный файл |
|----------|----------|----------------|
| Общие правила публикации и оформления раздач | t=6372776 | [general.md](general.md) |
| Правила для lossless подразделов | t=6372775 | [lossless.md](lossless.md) |
| Правила для lossy (MP3) подразделов | t=6372774 | [lossy.md](lossy.md) |
| Правила оцифровок с аналоговых носителей | t=3558517 | [vinyl-digitization.md](vinyl-digitization.md) |
| Правила для Hi-Res музыки | t=5093969 | [hi-res.md](hi-res.md) |
| Правила оформления Лейбл-Паков | t=3746663 | [label-packs.md](label-packs.md) |
---
## Специальные правила
| Документ | Topic ID | Локальный файл |
|----------|----------|----------------|
| Разъяснения по дискографиям и коллекциям | t=6372771 | [discography.md](discography.md) |
---
## Локальные правила по жанрам
| Раздел | Topic ID | Локальный файл |
|--------|----------|----------------|
| Классическая музыка | t=773016 | [classical.md](classical.md) |
| Саундтреки | t=2090617, t=2044619 | [soundtracks.md](soundtracks.md) |
| Джаз | t=3510475 | [jazz.md](jazz.md) |
| Зарубежный Metal | t=4481510 | [metal.md](metal.md) |
| Неофициальные сборники (lossless) | t=5555404 | - |
| Неофициальные сборники (lossy) | t=2661504 | - |
---
## Структура общих правил (t=6372776)
### 1. Введение
- 1.1 О музыкальных разделах трекера
- 1.2 На какие разделы распространяются правила
- 1.3 Таблица публикации по форматам
- 1.4 О дополнительных файлах
### 2. Требования к качеству
- 2.1 Источники музыки и форматы
- 2.2 Проверка на повтор
- 2.3 Запрет апконвертов
- 2.4 Запрет автоматического ремастеринга
- 2.5 Запрет смешения lossless и lossy
- 2.6 Запрет смешения Red Book и Hi-Res
- 2.7 Запрет сборников разных жанров
- 2.8 Запрет дискографий (с исключениями)
### 3. Требования к файлам
- 3.1 Запрет data-дорожек
- 3.2 Запрет дефектных файлов
- 3.3 Требования к названию папки
- 3.4 Требования к названию файлов
- 3.5 Обязательные теги
- 3.6 Запрет Track01/Unknown Artist
- 3.7 Запрет транслитерации
- 3.8 Требования к году и обложке
### 4. Требования к оформлению
- 4.1 Заполнение шаблона
- 4.2 Запрет CAPS LOCK
- 4.3 Язык названия
- 4.4 Содержание заголовка
- 4.5 Указание жанра
- 4.6 Транслитерация
- 4.7 Многодисковые издания
- 4.8 Соответствие жанру
- 4.9 Требования к обложке
- 4.10-4.16 Дополнительные требования
---
## Структура правил lossless (t=6372775)
### 2. Требования к материалу
- 2.1 Допустимые форматы (Red Book 16/44.1)
- 2.2 Запрет несжатых WAV
- 2.3 Рекомендуемые программы (EAC, XLD)
- 2.4 Требования к логам
- 2.5 Типы рипов (image+cue, tracks+cue)
- 2.6 Enhanced CD требования
### 3. Оформление
- 3.1 Публикация LOG
- 3.2 Отчёт DR
- 3.3 Указание источника
- 3.4 Публикация CUE
- 3.5 ISO-контейнеры
- 3.6 Запрет замены логов ссылками
### 4. WEB-релизы
- 4.1 Определение
- 4.2 Обязательные требования
- 4.3 Поглощение CD-рипами
### 5. AI-музыка
- 5.1 Определение
- 5.2 Требования (тег [AI], ссылка, DR, "as is")
---
## Структура правил Hi-Res (t=5093969)
### 2. Требования к материалу
- 2.1 Допустимые форматы Hi-Res
- 2.1.1 PCM: 24/48 - 24/192
- 2.1.2 WEB-релизы
- 2.1.3 DVD с LPCM/DSD
- 2.1.4 Многоканальные: SACD, Blu-Ray, DVD-Audio
- 2.2 Запрещённые форматы
- 2.3 Рекомендации по ISO
- 2.4 DTS и извлечённые треки
- 2.5 Не считаются повтором
### 3. Оформление
- 3.1-3.2 Шаблон
- 3.3 Теги в заголовке ([TR24], [OF], etc.)
- 3.4 Название папки
- 3.5 Название файлов
- 3.6 Ссылка на магазин
- 3.7 DR meter
- 3.8 Техническая информация
### 4. Программы
- 4.1 Для DSD
- 4.2 Для остальных форматов
---
## Ключевые URL
```
# Базовый URL
https://rutracker.org/forum/viewtopic.php?t=
# Основные правила
6372776 - Общие правила
6372775 - Lossless
6372774 - Lossy
3558517 - Оцифровки
5093969 - Hi-Res
# Специальные
6372772 - Статус "Сомнительно"
6372771 - Дискографии
3746663 - Лейбл-паки
# Жанровые
773016 - Классика
3510475 - Джаз (стили)
4481510 - Metal (lossless)
2090617 - Саундтреки (жанры)
```
---
## Скачанные документы
1. ✅ [general.md](general.md) - Общие правила
2. ✅ [lossless.md](lossless.md) - Lossless правила
3. ✅ [lossy.md](lossy.md) - Lossy правила
4. ✅ [vinyl-digitization.md](vinyl-digitization.md) - Оцифровки
5. ✅ [hi-res.md](hi-res.md) - Hi-Res правила
6. ✅ [label-packs.md](label-packs.md) - Лейбл-паки
7. ✅ [discography.md](discography.md) - Дискографии
8. ✅ [classical.md](classical.md) - Классика
9. ✅ [soundtracks.md](soundtracks.md) - Саундтреки
10. ✅ [jazz.md](jazz.md) - Джаз
11. ✅ [metal.md](metal.md) - Metal
+263
View File
@@ -0,0 +1,263 @@
# Правила оформления раздач в разделе ДЖАЗА RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=1412059
**Дополнительные источники:**
- t=1080830 (Требования к раздачам в разделе Джаз и Блюз)
- t=3510475 (Требования по использованию тэгов стилей)
---
## 1. Что можно размещать
### 1.1 Обязательно использовать поиск
Проверить дубликаты по исполнителю и названию.
### 1.2 Названия
На оригинальном языке, без перевода.
Для нелатинских языков - транскрипция в скобках.
### 1.3 Сборники
**Запрещены неофициальные сборники:**
- "Все песни артиста"
- "Все хиты за N-ный год"
- "Танцевальная музыка года"
**Требования к сборникам:**
- Размер: до 700 МБ (lossy)
- Треков: до 200
**Разрешены:**
- Официальные компиляции
- Тематические сборники жанра
---
## 2. Технические требования
### 2.1 Форматы Lossless
**Запрещены:**
- Несжатые образы: WAV, NRG, MDF/MDS
- Архивы: RAR, ZIP
- Исполняемые файлы: EXE, COM
**Рекомендуемые кодеки:**
- FLAC
- APE
- WavPack
### 2.2 Типы рипов
1. **Image+CUE+LOG** - полный образ
2. **Tracks+Non-compliant CUE+LOG** - потреково
### 2.3 LOG-файл
Создавать **EAC** (Exact Audio Copy).
```bbcode
[spoiler="Отчет EAC"]
Содержимое лог-файла
[/spoiler]
```
**Если источник неизвестен:**
```
Рип сделан: неизвестно
Источник: название источника
```
+ скриншот спектра Tau Analyzer или Adobe Audition.
### 2.4 Запрещённые режимы
🚫 Burst (скоростной)
🚫 Paranoid (параноидальный)
🚫 Normalize (нормализация)
✅ Только **Secure** (безопасный)
### 2.5 Апконверты
3 апконвертированных трека = повтор.
Проверять через **Tau Analyzer**.
---
## 3. Оформление раздачи
### 3.1 Запреты в названии
🚫 Только ВЕРХНИЙ РЕГИСТР
🚫 Точки (.) кроме пунктуации
🚫 Нижние подчёркивания (_) вместо пробелов
🚫 Жанр в названии (указывается в поле шаблона)
### 3.2 Обложка
- Размер: **500×500 px** (рекомендуется)
- Минимум: **200×200 px**
- 🚫 Реклама сторонних ресурсов
- Сканирование: 300-600 dpi
### 3.3 Лейбл
По-английски или транслитерация:
- `Blue Note`
- `Melodiya` или `Мелодия`
### 3.4 Информация о записи
**Крайне желательно для джаза:**
```
Recorded: December 9, 1965
Studio: Van Gelder Studio, Englewood Cliffs, NJ
Personnel:
John Coltrane - tenor saxophone
McCoy Tyner - piano
Jimmy Garrison - bass
Elvin Jones - drums
```
---
## 4. Тэги стилей (обязательно!)
**"Jazz" недостаточно** - необходимо указывать конкретный стиль.
### Early Jazz, Swing, Gypsy
- Boogie-Woogie
- Ragtime
- Dixieland
- Classic Jazz
- New Orleans Jazz
- Big Band
- Swing
- Gypsy
### Bop
- Bop
- Hard Bop
- Post-Bop
- Neo-Bop
- Modal Music
- Modern Big Band
### Mainstream Jazz, Cool
- Mainstream Jazz
- Cool
- West Coast Jazz
- Soul-Jazz
- Standards
### Jazz Fusion
- Fusion
- Jazz-Rock
- Jazz-Funk
- Jazzy Blues
### World Fusion, Ethnic Jazz
- World Fusion
- Latin Jazz
- Bossa Nova
- Afro-Cuban Jazz
- Brazilian Jazz
- Tango Nuevo
### Avant-Garde Jazz, Free Improvisation
- Avant-Garde Jazz
- Free Jazz
- Free Funk
- Experimental Jazz
- M-Base
### Modern Creative, Third Stream
- Modern Creative
- Third Stream
- Chamber Jazz
- Progressive Jazz
### Smooth, Jazz-Pop
- Smooth Jazz
- Crossover Jazz
- Jazz-Pop
- Easy Listening
### Vocal Jazz
- Vocal Jazz
- Acapella
### Funk, Soul, R&B
- Funk
- Soul
- R&B
- Gospel
---
## 5. Треклист
### 5.1 Обязательное содержание
- Порядковый номер
- Название
- Продолжительность (желательно)
Для сборников - исполнитель для каждого трека.
### 5.2 50+ треков - под спойлер
```bbcode
[spoiler="Треклист"]
01. Track Name (5:23)
...
[/spoiler]
```
### 5.3 Битрейт
Если разный - указывать для каждого трека.
---
## 6. Название папки
```
Artist Name - Album Title (Year) [Format]
```
Пример:
```
John Coltrane - A Love Supreme (1965) [FLAC]
```
---
## 7. Источник
```
Источник: What.CD
Релизёр: username
```
---
## Паттерны для парсера
```regex
# Джаз стили
(?:Hard |Post-|Neo-)?Bop|Swing|Dixieland|Big\s*Band|Fusion|Cool|Latin\s*Jazz|Bossa\s*Nova|Free\s*Jazz|Smooth\s*Jazz|Soul[\s-]?Jazz|Avant[\s-]?Garde
# Personnel формат
Personnel:\s*\n((?:[^\n]+\s*-\s*[^\n]+\n?)+)
# Recorded дата
Recorded:\s*([A-Za-z]+\s+\d+,?\s+\d{4})
# Studio/Location
(?:Studio|Location):\s*([^\n]+)
# Название папки
([^-]+)\s*-\s*([^(]+)\s*\((\d{4})\)\s*\[([A-Z]+)\]
```
+185
View File
@@ -0,0 +1,185 @@
# Правила оформления раздач Лейбл Паков (Label Pack)
**Источник:** https://rutracker.org/forum/viewtopic.php?t=3746663
---
## Важное примечание
**Создавать раздачу необходимо в жанровом подразделе, в раздел Лейбл Паков она будет перенесена модератором после проверки**
---
## Перед созданием раздачи вы должны:
**1.** Проверить по поиску, не существует ли аналогичная раздача на трекере
[Как пользоваться Поиском?](https://rutracker.org/forum/viewtopic.php?t=101236)
**2.** Ознакомиться с правилами оформления раздач в разделе Музыка
- [Общие правила оформления раздач в разделе Музыка](https://rutracker.org/forum/viewtopic.php?t=6372776)
- [Правила публикации и оформления раздач в lossless разделах категорий Музыка, Рок-музыка, Электронная музыка](https://rutracker.org/forum/viewtopic.php?t=6372775)
---
## Что такое Лейбл Пак?
**Лейбл Пак (Label Pack)** - это сборная раздача, включающая в себя релизы музыкантов, изданные одним лейблом и его суб-лейблами.
---
## Оформление заголовка
Заголовок - это краткая информация по всей вашей раздаче, поэтому заголовок темы должен быть максимально информативным.
### Формат заголовка:
```
(Жанр) Label: Название лейбла (количество релизов), - диапазон лет (Аудиокодек), битрейт (тип рипа)
```
### Компоненты заголовка:
- **(Жанр)** - в скобках указывается жанр, обычно сюда пишется 3 жанра, которые являются преимущественными для вашей раздачи
- **Название лейбла** - Указание на то, что раздача принадлежит к числу Лейбл паков
- Например: `Label: Acroplane Recordings`
- **(количество релизов)** - укажите общее количество альбомов, которые содержит ваша раздача
- **диапазон лет, за период которые собраны релизы**
- Пример: `2000-2010 г.`
- **Аудиокодек** - Кодек, которым сжаты ваши аудиофайлы, самый распространённый - это `.flac` (`*FLAC`). Если в раздаче треки сжаты несколькими кодеками, то их нужно указать через запятую
- **битрейт** - качество релизов в вашей раздаче (для lossless достаточно так и указать - `lossless`)
- **Тип рипа** - потрековый или образом, если типов рипа несколько, то они указываются через запятую
### Пример оформленного заголовка:
```
(IDM, Experimental, Ambient) Label: 33 Recordings (55 releases), 2008 - 2011, (FLAC) lossless (tracks+.cue)
```
---
## Создание и оформление раздачи
### Требования к раздаваемому материалу
#### Обязательное требование к названию папки
Каждый релиз должен быть помещён в отдельную папку, в названии которой должны быть указаны:
```
[каталожный номер] Название исполнителя - Название альбома (год)
```
**Важно:**
- Каталожный номер всегда должен быть прописан в начале названия папки
- Все папки в раздаче должны быть приведены к одному виду. Если, к примеру, вы решили указывать год в круглых скобках, то это должно быть применено для каждой папки. Это так же касается сцен-релизов.
---
### Требования по оформлению раздач
Каждый альбом в раздаче должен быть представлен под отдельным спойлером, в который должна быть включена следующая информация:
1. Название исполнителя
2. Название альбома
3. Год выхода альбома
4. Каталожный номер
5. Жанр
6. Треклист
7. Обложка (Если существует)
8. Битрейт
---
### Пример оформления альбома
```
[spoiler="[SEMANTICA 05] Various - Prologue [2008] - CD"]
[IMG=right]http://i6.imageban.ru/out/2017/09/01/f829816b7a97df780abb366bfcd93792.jpg[/IMG]
[font=mono1][b]Жанр: [/b] Techno, IDM, Dub Techno
[b]Продолжительность[/b]:00:58:58
[b]01.[/b] Julien Neto Reprise [i](03:58)[/i]
[b]02.[/b] Arcanoid Sad (Talking About) [i](07:02)[/i]
[b]03.[/b] Acid Future Overdose 99926 [i](08:10)[/i]
[b]04.[/b] Jimmy Edgar Warm Play Look Away [i](05:12)[/i]
[b]05.[/b] Oscar Mulero Paris, Texas [i](06:32)[/i]
[b]06.[/b] Ed Chamberlain Does Ape [i](06:07)[/i]
[b]07.[/b] Ideograma Alert S.E.T.I. Home [i](06:19)[/i]
[b]08.[/b] Plant43 Extrasolar [i](04:38)[/i]
[b]09.[/b] Annie Hall Wine & Beats [i](04:50)[/i]
[b]10.[/b] Svreca Eye (DisinVectant Optic Nerve Remix) [i](06:10)[/i]
[hr]
[url=https://www.discogs.com/Various-Prologue/release/1521766][img]http://i59.fastpic.ru/big/2013/0914/11/277b80b65490a21939363761aa55d111.png[/img][/url]
[spoiler="Лог создания рипа"][pre]
Exact Audio Copy V0.99 prebeta 5 from 4. May 2009
EAC extraction logfile from 18. November 2010, 17:14
...
[/pre][/spoiler]
[spoiler="Содержание индексной карты (.CUE)"][pre]
REM GENRE Electronic
REM DATE 2008
REM DISCID 880DD10A
...
[/pre][/spoiler]
[/spoiler]
```
---
## Дополнительные требования
### Обязательно:
- **Обязательно** для лейбл паков указывать **порелизовую продолжительность** (продолжительность звучания каждого релиза), так как бывают различные вариации альбомов, когда набор треков одинаков, но длительность бывает различна.
- **Обязательно** указание **потрековой продолжительности** для веб релизов, входящих в состав раздачи
- **Обязательно** указание жанровой принадлежности каждого альбома, так как далеко не все лейблы специализируются на каком-то определенном жанре.
- **Допускается** не указывать жанровую принадлежность для каждого альбома в случаях, когда она одинакова для всех альбомов в раздаче
### Приветствуется:
- **Приветствуется** указание общей продолжительности звучания всех альбомов в раздаче
- **Приветствуется** указание потрековой продолжительности всех альбомов, входящих в состав раздачи, включая CD и Vinyl рипы
- **Приветствуется** указание типа носителя, с которого был снят рип: CD, Vinyl, Cassette, WEB
---
## Дополнительная информация
- В случае, если оформление раздачи превышает допустимый лимит символов в сообщении, то вы можете продолжить оформление в следующем сообщении.
- [Пример оформленной раздачи](https://rutracker.org/forum/viewtopic.php?t=4542920)
- [Ускорение оформления сборных раздач (дискографий, коллекций) при помощи универсальной программы редактирования тегов - mp3tag](https://rutracker.org/forum/viewtopic.php?t=3024659)
- **Одиночные раздачи альбомов, входящих в состав Лейбл пака, не поглощаются**
- **В случае обновления старой раздачи, новая раздача должна полностью соответствовать всем требованиям по оформлению раздач лейбл паков**
---
## Особенности lossless Лейбл паков
Оформление lossless Лейбл пака принципиально не отличается от оформления lossy лейбл пака, за исключением обязательности публикации:
- Логов снятия рипа
- cue-sheet
- Или логов проверки качества (при условии, если лог снятия рипа отсутствует)
---
**Документ создан:** 2026-05-04
**Последнее обновление на RuTracker:** 06-июн-20 22:39
+171
View File
@@ -0,0 +1,171 @@
# Правила для lossless подразделов RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372775
**Версия:** © 2023
**Последнее обновление:** 05.11.2025
## 1. Введение
Настоящие правила являются дополнительными по отношению к правилам, изложенным в общей части, и описывают специфические требования к публикации и оформлению раздач в lossless-разделах категорий форума Музыка, Популярная музыка, Джазовая и Блюзовая музыка, Рок-музыка, Электронная музыка.
## 2. Требования к раздаваемому материалу
### 2.1 Допустимые форматы и источники
В lossless-подразделах раздаётся исключительно:
- **Рипы с CD**
- **WEB-релизы** с характеристиками **16 бит / 44100 Гц** (стандарт Red Book)
**Исключение:** Если релиз содержит 50% и более материала, отличного от Red Book, его следует оформлять в Hi-Res подразделах.
### 2.2 Требования к упаковке
**Категорически запрещается** раздача WAV-файлов, не упакованных lossless-кодеками.
**Степень сжатия FLAC:** не менее 5 (рекомендуется 8)
> ⚠️ Не путать WavPack (*.wv) с PCM Wave (*.wav)
### 2.3 Запрет на виртуальные приводы
**Категорически запрещается** публикация рипов с виртуальных приводов.
### 2.4 Рекомендуемые программы
- **EAC (Exact Audio Copy)** - для Windows и Linux (Wine)
- **XLD (X Lossless Decoder)** - для Mac OS
Только рипы по официальным инструкциям получают статус "проверено".
### 2.5 Равнозначные форматы раздач
Равнозначными считаются:
1. Рипы **image+cue+log**
2. Рипы **tracks+non-compliant cue+log**
Потрековая раздача = повтор при наличии образа, и наоборот.
### 2.6 Равнозначность lossless-кодеков
Все lossless-кодеки равнозначны (FLAC, APE, WavPack, etc.). Рип в одном кодеке не поглощает рип в другом - это повторы.
### 2.7 Enhanced CD (Extra CD)
**Допускается** включать бонусы (видеоклипы, mp3, flash) если они были на оригинальном диске.
Требования:
- 2-3 скриншота для каждого видеофайла
- Отчёт MediaInfo под спойлером
## 3. Требования к оформлению
### 3.1 Публикация лога EAC/XLD
```bbcode
[spoiler="Лог создания рипа"][pre]содержание лог-файла[/pre][/spoiler]
```
### 3.2 Отчёт о динамическом диапазоне (DR)
Обязателен для раздач без лога извлечения (WEB-релизы, авторский материал):
```bbcode
[spoiler="Динамический отчет (DR)"][pre]Содержимое лога[/pre][/spoiler]
```
**Программы для DR:**
- **Windows:** foobar2000 + Dynamic Range Meter plugin
- **Linux:** DeaDBeeF + dr-meter plugin
- **Mac OS:** foobar2000 + plugin
### 3.3 Указание источника рипа
Обязательно указать:
- **"Собственный рип"** или **"Сторонний рип"**
- Для сторонних: название ресурса (без ссылки), ник релизера
### 3.4 Публикация CUE Sheet
Независимо от типа рипа:
```bbcode
[spoiler="Содержание индексной карты (.CUE)"][pre]содержание[/pre][/spoiler]
```
### 3.5 ISO-контейнеры
Для рипов в iso.wv - указать перечень файлов контейнера под отдельным спойлером.
### 3.6 Запрет замены логов ссылками
**Не допускается** заменять лог сообщениями типа "лог внутри раздачи".
## 4. WEB-релизы в lossless
### 4.1 Определение
WEB-релиз - материал в lossless через цифровую дистрибуцию:
- Интернет-магазины (Beatport, Juno)
- Сайты лейблов и исполнителей
- Авторские раздачи
### 4.2 Обязательные требования
1. **Указание [WEB]** в заголовке
2. **Ссылка на источник** (магазин, сайт лейбла)
- Если неизвестен: "WEB-магазин: неизвестен"
- Если авторская: "WEB-магазин: материал предоставлен авторами"
3. **Заполнение поля "Источник"**
- Собственная покупка
- Авторская раздача
- Название ресурса
4. **Отчёт DR**
### 4.3 Поглощение WEB CD-рипами
WEB-релиз в Red Book может быть поглощён правильным CD-рипом при совпадении мастеринга.
**Условия совпадения мастеринга:**
- Совпадение DR value / Samplerate / Bits per sample / Channels
- Погрешность Peak: ±0.2 dB
## 5. AI-музыка в lossless
### 5.1 Определение
AI Generated Music (AIGM) - материалы, сгенерированные с помощью ИИ.
### 5.2 Требования
1. **Тег [AI]** в заголовке
2. **Ссылка на источник** (кроме собственной генерации)
3. **Отчёт DR**
4. **Публикация "as is"** - без ремастеринга
## История изменений
- **24.11.2023** — добавлен п. 2.7
- **17.07.2024** — изменены пп. 3.1, 3.2, 4.2.4, 4.3
- **17.01.2025** — удален п. 2.7, добавлен п. 5
- **27.02.2025** — изменен п. 5.2.2, добавлен п. 5.2.4
- **05.11.2025** — изменен п. 4.3
## Паттерны для парсера
```regex
# WEB тег
\[WEB\]
# AI тег
\[AI\]
# FLAC степень сжатия
FLAC\s*(?:level\s*)?([5-8])
# Источник рипа
(?:Собственный|Сторонний)\s+рип
# Red Book формат
16\s*(?:bit|бит).*44[.,]?1\s*(?:kHz|кГц)
```
+104
View File
@@ -0,0 +1,104 @@
# Правила для lossy (MP3) подразделов RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372774
## 1. Введение
Настоящие правила являются дополнительными по отношению к правилам, изложенным в общей части, и описывают специфические требования к публикации и оформления раздач в lossy-разделах категорий форума Музыка, Популярная музыка, Джазовая и Блюзовая музыка, Рок-музыка, Электронная музыка.
Применимые форматы: MP3, AAC, OggVorbis, Musepack, WMA.
## 2. Требования к раздаваемому материалу
### 2.1 Запрещено включать в раздачу:
- Видеоклипы
- Скринсейверы
- Обои
- RAW/lossless сканы обложек
### 2.2 Разрешено включать:
- Аудио бонусы
- Сканы обложек альбома (JPG)
- Пресс-релизы
### 2.3 Обложки в тегах:
- Максимум 1 изображение на трек
- Максимум 300 KB на изображение
## 3. Требования к оформлению
### 3.1 Обозначение битрейта
**CBR (постоянный битрейт):**
- 320 kbps
- 256 kbps
- 192 kbps
- 128 kbps
**VBR (переменный битрейт) - пресеты:**
- V0 (максимальное качество, ~245 kbps)
- V1 (~225 kbps)
- V2 (~190 kbps)
**VBR (переменный битрейт) - диапазон:**
- VBR 192-320 kbps
- VBR 128-320 kbps
**VBR (переменный битрейт) - средний:**
- VBR ~256 kbps (avg)
### 3.2 Многоальбомные релизы
Каждый альбом должен быть под отдельным спойлером с указанием:
- Год выпуска
- Название альбома
- Битрейт
## 4. Правила AI-музыки (добавлено январь 2025)
### 4.1 Обязательный тег
В заголовке раздачи должен быть тег **[AI]**
### 4.2 Ссылка на источник
Обязательно указать ссылку на страницу покупки (кроме самостоятельно сгенерированного материала)
### 4.3 Публикация "as is"
Материал должен публиковаться без ремастеринга, в исходном виде
## Шаблоны заголовков
### Стандартный формат:
```
(Жанр) Исполнитель - Название Альбома - Год, Формат Битрейт
```
### Примеры:
```
(Rock) Pink Floyd - The Wall - 1979, MP3 320 kbps
(Electronic) Daft Punk - Random Access Memories - 2013, MP3 V0
(Pop) Madonna - Like a Prayer - 1989, MP3 VBR 192-320 kbps
```
### С тегом AI:
```
(Electronic) [AI] AI Artist - Generated Album - 2025, MP3 320 kbps
```
## Паттерны для парсера
```regex
# CBR битрейт
(\d{2,3})\s*kbps
# VBR пресеты
V[012]
# VBR диапазон
VBR\s+(\d+)-(\d+)\s*kbps
# VBR средний
VBR\s+~?(\d+)\s*kbps
# AI тег
\[AI\]
```
+189
View File
@@ -0,0 +1,189 @@
# Правила оформления раздач в разделе ЗАРУБЕЖНЫЙ METAL RuTracker
**Источник:** Compiled from RuTracker.org forum rules
**Примечание:** Прямой доступ к t=1412060 требует авторизации
## 1. Формат заголовка
```
(Жанр/Подстиль) Название группы - Название альбома - Год, Аудиокодек, Битрейт
```
### Примеры:
```
(Heavy/Power/Thrash Metal) Metal Church - Dead to Rights - 2026, MP3, 320 kbps
(Black Metal) Darkthrone - A Blaze in the Northern Sky - 1992, FLAC (image+.cue), lossless
(Death Metal, Doom Metal) Paradise Lost - Gothic - 1991, APE (tracks), lossless
```
## 2. Обозначение жанров Metal
### Основные жанры:
- Heavy Metal
- Power Metal
- Thrash Metal
- Speed Metal
- Progressive Metal
- Death Metal
- Black Metal
- Doom Metal
- Gothic Metal
- Symphonic Metal
- Folk Metal
- Viking Metal
- Pagan Metal
- Grindcore
- Metalcore
- Deathcore
### Формат указания:
- Жанры через запятую: `Death Metal, Doom Metal`
- Жанры через слэш: `Heavy/Power Metal`
- В скобках в начале заголовка
## 3. Обязательные элементы
### В заголовке:
1. Жанр (в скобках)
2. Название группы (оригинальное написание)
3. Название альбома
4. Год издания
5. Аудиокодек (MP3, FLAC, APE)
6. Битрейт/тип рипа
### В описании:
1. Жанр
2. Страна группы
3. Год издания
4. Аудиокодек
5. Тип рипа (tracks / image+cue)
6. Битрейт
7. Продолжительность
8. Наличие сканов
9. Треклист
10. Лейбл
## 4. Требования к тегам
### Обязательные теги:
1. **Название песни** - оригинальное написание
2. **Альбом** - с дополнениями (Single, [EP], Live)
3. **Исполнитель** - оригинальное написание (W.A.S.P., не WASP)
4. **Год** - год альбома, не переиздания
5. **Номер трека**
6. **Номер диска** (для многодисковых)
7. **Жанр**
8. **Обложка** (200×200 - 600×600 px)
## 5. Типы релизов
### Форматы альбомов:
- **Studio Album** - студийный альбом
- **Live** - концертная запись
- **EP** - мини-альбом
- **Single** - сингл
- **Compilation** - сборник
- **Demo** - демо-запись
- **Bootleg** - бутлег
- **Remaster** / **Reissue** - ремастер / переиздание
- **Deluxe Edition** - делюкс издание
- **Box Set** - бокс-сет
### Специальные издания:
- **(Japan Edition)**
- **(Limited Edition)**
- **(Digipak)**
- **(Vinyl)**
- **(Anniversary Edition)**
## 6. Требования к обложке
- Разрешение: 200×200 - 500×500 px (описание)
- Для тегов: 200×200 - 600×600 px
- Размер файла: до 300-500 KB
- 🚫 Анимация запрещена
- 🚫 Реклама запрещена
## 7. Треклист
### Формат:
```
01. Название трека (04:30)
02. Название трека (05:15)
```
### Для сборников:
```
01. Исполнитель - Название трека (04:30)
```
Треклисты более 50 треков - под спойлер.
## 8. Локальные правила lossless
### Критерии собственного рипа:
1. Последняя версия EAC или XLD
2. Режим Test & Copy
3. Учёт нулевых семплов включён
4. Полный комплект сканов
5. Ник релизёра в логе
### Пример комментария в логе:
```
Additional command line options : -T "COMMENT=Ripped by [nickname]" -8 -V %s
```
## 9. Дискографии Metal
### Формат папок:
```
[Каталожный номер] Название группы - Название альбома (год)
```
### Требования:
- Единообразие всех папок
- Каждый альбом под спойлером
- Жанр для каждого альбома (если различаются)
## 10. Запреты
### Запрещено раздавать:
- Неполные релизы
- Транскоды (апконверты)
- Сборники собственного составления
- AI-музыку
### Запрещено объединять:
- Lossless и lossy
- Red Book и Hi-Res
- Официальные и неофициальные релизы
### Запрещено в тегах:
- Графика кроме обложки
- "Трек 1", "CD 1" вместо названий
## 11. Статусы раздач
- **✓ Проверено** - соответствует правилам
- **? Сомнительно** - вопросы к качеству
- **! Не оформлено** - не соответствует требованиям
- **Повтор** - дублирует существующую
## Паттерны для парсера
```regex
# Metal жанры
(?:Heavy|Power|Thrash|Speed|Progressive|Death|Black|Doom|Gothic|Symphonic|Folk|Viking|Pagan|Grind|Metal)(?:core)?(?:\s*Metal)?
# Комбинированные жанры
\(([A-Za-z/,\s]+Metal[A-Za-z/,\s]*)\)
# Тип релиза
\[(EP|Single|Live|Demo|Remaster|Reissue|Deluxe Edition|Box Set)\]
# Специальные издания
\((Japan|Limited|Anniversary)\s*Edition\)|\(Digipak\)|\(Vinyl\)
# Название группы (с точками)
([A-Z][A-Za-z.]+(?:\s+[A-Z][A-Za-z.]+)*)
```
+144
View File
@@ -0,0 +1,144 @@
# Правила оформления раздач саундтреков RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=3119778
## 1. Общие требования
### 1.1 Поиск перед созданием
Обязательно проверить наличие аналогичной раздачи.
### 1.2 Качество
Раздача более низкого битрейта закрывается как повтор при наличии более качественной.
### 1.3 Минимум треков
Не менее 3 треков в раздаче.
### 1.4 Форматы
🚫 Lossless запрещён для неофициальных саундтреков (только официальные саундтреки в lossless).
## 2. Формат заголовка
### 2.1 Soundtrack (песенная компиляция)
```
(Soundtrack / Unofficial) Название фильма (русское) / Original Title - Год, Формат Битрейт
```
### 2.2 Score (оригинальная инструментальная музыка)
```
(Score / Unofficial) Композитор - Название фильма (русское) / Original Title - Год, Формат Битрейт
```
### Примеры:
```
(Soundtrack / Unofficial) Матрица / The Matrix - 1999, MP3 320 kbps
(Score / Unofficial) Hans Zimmer - Начало / Inception - 2010, MP3 V0
```
## 3. Обязательные поля
### 3.1 Жанр
```
(Soundtrack / Unofficial)
(Score / Unofficial)
```
### 3.2 Название фильма
Формат: Русское название / Оригинальное название
### 3.3 Композитор
**Обязательно только для Score.**
### 3.4 Год
Год выхода фильма/сериала (не саундтрека).
### 3.5 Продюсер/Страна
Информация о производстве фильма.
## 4. Обложка
**Размер:** 200x200 - 500x500 пикселей
Рекомендуется использовать:
- Постер фильма
- Официальную обложку саундтрека (если есть)
## 5. Треклист
### 5.1 Формат
```
01. Исполнитель - Название (продолжительность) - битрейт
02. Исполнитель - Название (продолжительность) - битрейт
```
### 5.2 Для Score
```
01. Название (продолжительность) - битрейт
```
(композитор указывается в заголовке)
## 6. Именование папки
```
Композитор - Название фильма - Unofficial Soundtrack (Год)
```
или
```
Название фильма - Unofficial Soundtrack (Год)
```
## 7. Именование файлов
✅ Правильно:
```
01 - Название
01. Название
01 Название
```
❌ Неправильно:
```
Название - 01
01Название
```
## 8. Различие Soundtrack и Score
### Soundtrack
- Песенная компиляция
- Различные исполнители
- Песни, использованные в фильме
- Не всегда оригинальные записи
### Score
- Оригинальная инструментальная музыка
- Один композитор (или группа)
- Написана специально для фильма
- Фоновая музыка, темы
## Паттерны для парсера
```regex
# Soundtrack тип
\(Soundtrack\s*/?\s*(?:Unofficial|Official)?\)
# Score тип
\(Score\s*/?\s*(?:Unofficial|Official)?\)
# OST тег
\[?OST\]?|O\.?S\.?T\.?
# Название фильма двуязычное
([^/]+)\s*/\s*([^-]+)
# Год фильма
-\s*(\d{4})\s*,
```
## Дополнительные ссылки
- [Официальные саундтреки lossless](https://rutracker.org/forum/viewforum.php?f=691)
- [Официальные саундтреки lossy](https://rutracker.org/forum/viewforum.php?f=469)
- [Саундтреки к играм](https://rutracker.org/forum/viewforum.php?f=786)
- [Саундтреки к аниме](https://rutracker.org/forum/viewforum.php?f=1631)
+128
View File
@@ -0,0 +1,128 @@
# Правила оцифровок с аналоговых носителей RuTracker
**Источник:** https://rutracker.org/forum/viewtopic.php?t=3558517
## 1. Введение
### 1.1 Область применения
Правила для музыкальных материалов, созданных оцифровкой аналогового звукового сигнала с:
- Виниловых пластинок
- Магнитофонных лент
- Других аналоговых носителей
Распространяется только на любительские оцифровки, не изданные официально на audio CD.
### 1.2 Связанные документы
- [Общие правила публикации](https://rutracker.org/forum/viewtopic.php?t=6372776)
- [Правила пользования форумом](https://rutracker.org/forum/viewtopic.php?t=1045)
## 2. Требования к содержанию раздачи
### 2.1 Запрещено включать:
1. **Lossy-форматы** (MP3, WMA, Ogg) - для них отдельные разделы
2. **WAV без lossless-сжатия** - только APE, FLAC (≥5), WV
3. **Многоканальное аудио** (DVD-Audio) - только PCM Wave в lossless
4. **Несколько вариантов одного носителя** - выкладывать отдельными раздачами
5. **Версии "до" и "после" обработки** - только финальная версия
6. **Неполный набор треков** официального носителя
7. **Видео** (кроме музыкальных клипов, караоке, концертов)
8. **Оцифровки с цифровых исходников** - для них раздел "Неофициальные сборники"
9. **PCM с частотой не кратной 44.1 или 48 кГц**
### 2.2 CUE-файл
Для image+.cue обязателен в кодировке Unicode.
## 3. Требования к оформлению
### 3.1 Обязательные поля шаблона
1. **Формат раздачи** - Bit/kHz (например, 24/96)
2. **Формат исходника** - Bit/kHz исходного материала
3. **Марка носителя** - тег носителя (см. ниже)
4. **Состояние проигрывателя** - марка, модель, картридж, игла, усилитель
5. **Дополнительные параметры** - звуковая карта, софт
6. **АЦП** - марка, модель, разрядность, частота записи
7. **Звуковая карта** - параметры оцифровки
8. **Состояние** - физическое состояние носителя
### 3.2 Теги марки носителя
| Тег | Описание |
|-----|----------|
| `[LP]` | Виниловая пластинка 12" (33 об/мин) |
| `[MINI-LP]` | Мини-альбом |
| `[EP]` | Extended Play |
| `[12"]` | 12-дюймовый сингл |
| `[10"]` | 10-дюймовая пластинка |
| `[7"]` | 7-дюймовый сингл (45 об/мин) |
### 3.3 Состояние винила (Grading)
| Код | Название | Описание |
|-----|----------|----------|
| **Mint/SS** | Новая, запечатанная | Идеальное состояние, не проигрывалась |
| **NM** | Near Mint | Почти идеальная, минимальные дефекты |
| **EX** | Excellent | Незначительные царапины, не влияющие на звук |
| **VG+** | Very Good Plus | Поверхностные шумы и щелчки |
| **VG** | Very Good | Заметные царапины, но проигрывается без проблем |
| **G** | Good | Среднее состояние, заметные шумы |
| **F/P** | Fair/Poor | Плохое состояние |
### 3.4 Оцифровки третьих лиц
Если параметры неизвестны, указать:
- Формат раздачи (Bit/kHz)
- Источник
- Автор оцифровки (если известен)
### 3.5 Обязательные графики
**Для image-рипов:** минимум 1 изображение каждого:
- Спектр
- АЧХ
- Уровень записи
**Для потрековых рипов:** минимум 2 изображения (первый и последний трек стороны)
**Требования к скриншотам:**
- Вертикальные и горизонтальные шкалы
- Кликабельные превью под спойлером
- Достаточное разрешение для чтения шкал
### 3.6 CUE для image-рипов
Публикация содержания .CUE в оформлении строго обязательна.
## Примеры заголовков
```
(Rock) [LP] [24/96] Pink Floyd - The Dark Side of the Moon - 1973, FLAC (image+.cue)
(Jazz) [LP] [NM] [24/192] Miles Davis - Kind of Blue - 1959, FLAC (tracks)
(Electronic) [EP] [VG+] [24/48] Kraftwerk - Autobahn - 1974, APE (image+.cue)
```
## Паттерны для парсера
```regex
# Тег носителя
\[(LP|MINI-LP|EP|12"|10"|7")\]
# Состояние винила
\[(Mint|SS|NM|EX|VG\+?|G|F/?P)\]
# Формат Bit/kHz
\[(\d+)/(\d+(?:\.\d+)?)\]
# Пример комбинированный
\[(LP|EP)\]\s*\[(NM|EX|VG\+?)\]\s*\[(\d+)/(\d+)\]
```
## Дополнительные ссылки
- [Теги в названиях тем оцифровок](https://rutracker.org/forum/viewtopic.php?t=2672956)
- [Рекомендации по оцифровке](https://rutracker.org/forum/viewtopic.php?t=966580)
- [Правила Hi-Res музыки](https://rutracker.org/forum/viewtopic.php?t=1422416)
Generated
+9 -9
View File
@@ -21,11 +21,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1775087534,
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
"lastModified": 1777988971,
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
"type": "github"
},
"original": {
@@ -93,11 +93,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1774748309,
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github"
},
"original": {
@@ -108,11 +108,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1777386324,
"narHash": "sha256-ItxAnpJ3qffijuQzMv72I9v/yi1nWHr67hqQaVuQV6c=",
"lastModified": 1778095059,
"narHash": "sha256-LW2nru9+O0oyR3lfzgzFLwTibhINoIL/dx2/1dBMKWU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a6c2d4f65f850a9fa8977da543f8f9949d4a1527",
"rev": "94a37dc9da62b41f0c70a91da739bc318d049c11",
"type": "github"
},
"original": {
+8 -36
View File
@@ -36,46 +36,10 @@
gofmt.enable = true;
};
};
music-agregator = pkgs.buildGoModule {
pname = "music-agregator";
version = "0.1.0";
src = ./.;
vendorHash = "sha256-gad5/pLGWyU45QiEvZJ8xEKNy4K2p5OykKE0nykzh8w=";
nativeBuildInputs = [
pkgs.protobuf
pkgs.protoc-gen-go
pkgs.protoc-gen-go-grpc
];
preBuild = ''
export HOME=$(mktemp -d)
mkdir -p pkg/metadatapb/metadata/v1
${pkgs.protobuf}/bin/protoc \
--plugin=protoc-gen-go=${pkgs.protoc-gen-go}/bin/protoc-gen-go \
--plugin=protoc-gen-go-grpc=${pkgs.protoc-gen-go-grpc}/bin/protoc-gen-go-grpc \
--proto_path=proto \
--go_out=pkg/metadatapb --go_opt=paths=source_relative \
--go-grpc_out=pkg/metadatapb --go-grpc_opt=paths=source_relative \
proto/metadata/v1/metadata.proto
'';
subPackages = [ "cmd/server" ];
postInstall = ''
mv $out/bin/server $out/bin/music-agregator
'';
};
in
{
formatter = pkgs.nixfmt-tree;
packages = {
default = music-agregator;
inherit music-agregator;
};
checks = {
inherit pre-commit-check;
};
@@ -86,7 +50,13 @@
buildInputs = with pkgs; [
pre-commit
gitleaks
plantuml
just
nixd
bruno
buf
protobuf
protoc-gen-go
protoc-gen-go-grpc
@@ -95,6 +65,8 @@
gopls
gotools
go-tools
opencode
];
};
};
+174
View File
@@ -0,0 +1,174 @@
module homelab.lan/music-agregator
go 1.26.2
require (
github.com/anacrolix/torrent v1.61.0
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3
github.com/jackc/pgx/v5 v5.9.2
github.com/mewkiz/flac v1.0.13
github.com/prometheus/client_golang v1.23.2
github.com/riverqueue/river v0.35.1
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/RoaringBitmap/roaring v1.2.3 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // indirect
github.com/anacrolix/chansync v0.7.0 // indirect
github.com/anacrolix/dht/v2 v2.23.0 // indirect
github.com/anacrolix/envpprof v1.4.0 // indirect
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
github.com/anacrolix/mmsg v1.0.1 // indirect
github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.2.2 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.3.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.6 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pion/webrtc/v4 v4.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/riverqueue/river/riverdriver v0.35.1 // indirect
github.com/riverqueue/river/rivershared v0.35.1 // indirect
github.com/riverqueue/river/rivertype v0.35.1 // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/goleak v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.21.1 // indirect
zombiezen.com/go/sqlite v0.13.1 // indirect
)
require (
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/grpc v1.81.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v2 v2.4.0
)
+710
View File
@@ -0,0 +1,710 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVOVmhWBY=
github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 h1:c02PsmoaChabVqAFm7pqPI1UIkDdDAjUaWa6ZmfxybQ=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8/go.mod h1:7stWJ39LeusmMI8mjJuhFNRqep//vx0AsaySRoK9or0=
github.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0=
github.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k=
github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=
github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
github.com/anacrolix/envpprof v1.4.0 h1:QHeIcrgHcRChhnxR8l6rlaLlRQx9zd7Q2NII6Zbt83w=
github.com/anacrolix/envpprof v1.4.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0=
github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=
github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M=
github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA=
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=
github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=
github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY=
github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw=
github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=
github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=
github.com/anacrolix/mmsg v1.0.1 h1:TxfpV7kX70m3f/O7ielL/2I3OFkMPjrRCPo7+4X5AWw=
github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc=
github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=
github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=
github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
github.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8=
github.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M=
github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk=
github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 h1:oLCfNgEOR3/Z98mSwmwTM1pcqCDb/1zIjxCNn7dzVaE=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1/go.mod h1:21cUWerw9eiu/3T3kyoChu37AVO+YFue1/H15qqubS0=
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8=
github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=
github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=
github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=
github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4=
github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d h1:2qVb9bsAMtmAfnxXltm+6eBzrrS7SZ52c3SedsulaMI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bits-and-blooms/bitset v1.2.2 h1:J5gbX05GpMdBjCvQ9MteIg2KKDExr7DrgK+Yc15FvIk=
github.com/bits-and-blooms/bitset v1.2.2/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA=
github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 h1:OyQmpAN302wAopDgwVjgs2HkFawP9ahIEqkUYz7V7CA=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916/go.mod h1:DADrR88ONKPPeSGjFp5iEN55Arx3fi2qXZeKCYDpbmU=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 h1:GClwZI0at7xwV0TpgUMTYr/DoTE7TJZ/tc29LcPcs7o=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
github.com/huandu/xstrings v1.3.0/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/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/riverqueue/river v0.35.1 h1:TK1LLGRdTWL7ARPbIUB+TqMnTYJ0GiCoy5Q/yEf5yBE=
github.com/riverqueue/river v0.35.1/go.mod h1:jDt0LimObI+5e6FVy7LyuIWfHftmV0wARmiK7W+9D64=
github.com/riverqueue/river/riverdriver v0.35.1 h1:zJx8SaQdMP7zVEfd8SDoe8KjVHCXoXoFfzt6v+SJtQg=
github.com/riverqueue/river/riverdriver v0.35.1/go.mod h1:Y+rQzz0uvh+pQI+mzJh3qgAGGNxestOWgjKa7mob87w=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1 h1:GL+ztwpXgIqBin/3wNzq8h1/H8befxl61/DlLvVCAAY=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1/go.mod h1:5Llh5ONCFsW67dLm5+OelSWTKhliQ989JLbVMwyuN2U=
github.com/riverqueue/river/rivershared v0.35.1 h1:XEHf7yj35p5Os5r6K08q9BVaAKsvWhP9hfxEr+MwXqg=
github.com/riverqueue/river/rivershared v0.35.1/go.mod h1:YqVk7bZoojLsx58kyQ6ZU2FHP91HP4whVj6MTCtih/c=
github.com/riverqueue/river/rivertype v0.35.1 h1:7SfjZ3Hkr7gRjItMHAUzJBAHIqx41yS/4yjVPQVtNfM=
github.com/riverqueue/river/rivertype v0.35.1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
+285
View File
@@ -0,0 +1,285 @@
package analysis
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/rs/zerolog/log"
"homelab.lan/music-agregator/internal/audio"
"homelab.lan/music-agregator/internal/database"
)
var audioExtensions = map[string]bool{
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
}
var trackNumberRegex = regexp.MustCompile(`^(\d+)[\s\-._]+`)
type ReleaseAnalyzer struct {
downloads *database.DownloadRepository
downloadFiles *database.DownloadFileRepository
albumReleases *database.AlbumReleaseRepository
trackReleases *database.TrackReleaseRepository
}
func NewReleaseAnalyzer(db *database.DB) *ReleaseAnalyzer {
return &ReleaseAnalyzer{
downloads: database.NewDownloadRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
}
}
func (a *ReleaseAnalyzer) Analyze(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
download, err := a.downloads.GetByID(ctx, downloadID)
if err != nil {
return nil, nil, fmt.Errorf("getting download: %w", err)
}
files, err := a.downloadFiles.GetByDownloadID(ctx, downloadID)
if err != nil || len(files) == 0 {
log.Info().Str("download_id", downloadID).Msg("no download files in DB, scanning filesystem")
scanned, scanErr := ScanAndHashFiles(contentPath)
if scanErr != nil {
return nil, nil, fmt.Errorf("scanning files: %w", scanErr)
}
for _, f := range scanned {
f.DownloadID = downloadID
}
if err := a.downloadFiles.CreateBatch(ctx, scanned); err != nil {
return nil, nil, fmt.Errorf("persisting scanned files: %w", err)
}
files = scanned
}
var audioFiles []*database.DownloadFile
var hasCoverArt, hasCueSheet, hasRipLog bool
for _, f := range files {
if audioExtensions["."+f.FileType] {
audioFiles = append(audioFiles, f)
}
switch f.FileType {
case "jpg", "jpeg", "png", "gif", "webp":
hasCoverArt = true
case "cue":
hasCueSheet = true
case "log":
hasRipLog = true
}
}
if len(audioFiles) == 0 {
return nil, nil, fmt.Errorf("no audio files found for download %s", downloadID)
}
var trackReleases []*database.TrackRelease
var totalSize int64
var totalDuration int
formatCounts := make(map[string]int)
var firstBitDepth, firstSampleRate, firstChannels int
var firstIsLossless bool
for i, f := range audioFiles {
fullPath := filepath.Join(contentPath, f.FilePath)
info, err := audio.Analyze(fullPath)
if err != nil {
log.Warn().Err(err).Str("path", f.FilePath).Msg("failed to analyze audio file")
info = &audio.TrackInfo{
Format: strings.ToUpper(f.FileType),
}
}
if i == 0 {
firstBitDepth = info.BitDepth
firstSampleRate = info.SampleRate
firstChannels = info.Channels
firstIsLossless = info.IsLossless
}
formatCounts[info.Format]++
totalSize += f.FileSize
totalDuration += info.DurationMs
trackNum := extractTrackNumber(f.FilePath)
title := extractTitle(f.FilePath)
tr := &database.TrackRelease{
Title: title,
TrackNumber: trackNum,
DiscNumber: 1,
Format: info.Format,
Channels: info.Channels,
FileSize: f.FileSize,
FilePath: f.FilePath,
}
if info.DurationMs > 0 {
dur := info.DurationMs
tr.DurationMs = &dur
}
if info.BitDepth > 0 {
bd := info.BitDepth
tr.BitDepth = &bd
}
if info.SampleRate > 0 {
sr := info.SampleRate
tr.SampleRate = &sr
}
if info.BitrateKbps > 0 {
br := info.BitrateKbps
tr.BitrateKbps = &br
}
trackReleases = append(trackReleases, tr)
}
dominantFormat := ""
maxCount := 0
for format, count := range formatCounts {
if count > maxCount {
dominantFormat = format
maxCount = count
}
}
var source *string
if hasRipLog {
s := "CD"
source = &s
}
release := &database.AlbumRelease{
AlbumID: download.AlbumID,
DownloadID: downloadID,
Format: dominantFormat,
Channels: firstChannels,
IsLossless: firstIsLossless,
Source: source,
TotalSize: totalSize,
TotalDurationMs: totalDuration,
TrackCount: len(audioFiles),
HasCoverArt: hasCoverArt,
HasCueSheet: hasCueSheet,
HasRipLog: hasRipLog,
Path: contentPath,
}
if firstBitDepth > 0 {
release.BitDepth = &firstBitDepth
}
if firstSampleRate > 0 {
release.SampleRate = &firstSampleRate
}
return release, trackReleases, nil
}
func (a *ReleaseAnalyzer) AnalyzeAndPersist(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
release, trackReleases, err := a.Analyze(ctx, downloadID, contentPath)
if err != nil {
return nil, nil, err
}
if err := a.albumReleases.DeleteByDownloadID(ctx, downloadID); err != nil {
log.Warn().Err(err).Str("download_id", downloadID).Msg("failed to delete existing album release")
}
if err := a.albumReleases.Create(ctx, release); err != nil {
return nil, nil, fmt.Errorf("creating album release: %w", err)
}
for _, tr := range trackReleases {
tr.AlbumReleaseID = release.ID
}
if err := a.trackReleases.CreateBatch(ctx, trackReleases); err != nil {
return nil, nil, fmt.Errorf("creating track releases: %w", err)
}
return release, trackReleases, nil
}
func extractTrackNumber(filePath string) int {
base := filepath.Base(filePath)
matches := trackNumberRegex.FindStringSubmatch(base)
if len(matches) >= 2 {
var num int
fmt.Sscanf(matches[1], "%d", &num)
return num
}
return 0
}
func extractTitle(filePath string) string {
base := filepath.Base(filePath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
name = trackNumberRegex.ReplaceAllString(name, "")
return strings.TrimSpace(name)
}
func IsAudioExtension(ext string) bool {
return audioExtensions[ext]
}
func ScanAndHashFiles(rootPath string) ([]*database.DownloadFile, error) {
var files []*database.DownloadFile
err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
ext := strings.ToLower(filepath.Ext(path))
relPath, _ := filepath.Rel(rootPath, path)
fileType := strings.TrimPrefix(ext, ".")
if fileType == "" {
return nil
}
df := &database.DownloadFile{
FilePath: relPath,
FileSize: info.Size(),
FileType: fileType,
}
if IsAudioExtension(ext) || ext == ".cue" || ext == ".log" {
hash, err := hashFile(path)
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("failed to hash file")
} else {
df.SHA256Hash = hash
now := time.Now()
df.VerifiedAt = &now
}
}
files = append(files, df)
return nil
})
return files, err
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hashing file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+35
View File
@@ -0,0 +1,35 @@
package audio
import (
"fmt"
"path/filepath"
"strings"
)
type TrackInfo struct {
Format string
BitDepth int
SampleRate int
Channels int
DurationMs int
BitrateKbps int
IsLossless bool
}
func Analyze(filePath string) (*TrackInfo, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return analyzeFLAC(filePath)
case ".mp3":
return analyzeMP3(filePath)
case ".aac", ".m4a", ".ogg", ".wav", ".ape", ".wv", ".alac":
return &TrackInfo{
Format: strings.ToUpper(strings.TrimPrefix(ext, ".")),
IsLossless: ext == ".wav" || ext == ".ape" || ext == ".wv" || ext == ".alac",
}, nil
default:
return nil, fmt.Errorf("unsupported audio format: %s", ext)
}
}
+27
View File
@@ -0,0 +1,27 @@
package audio
import (
"fmt"
"github.com/mewkiz/flac"
)
func analyzeFLAC(filePath string) (*TrackInfo, error) {
stream, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("parsing FLAC: %w", err)
}
defer stream.Close()
info := stream.Info
durationMs := int(info.NSamples * 1000 / uint64(info.SampleRate))
return &TrackInfo{
Format: "FLAC",
BitDepth: int(info.BitsPerSample),
SampleRate: int(info.SampleRate),
Channels: int(info.NChannels),
DurationMs: durationMs,
IsLossless: true,
}, nil
}
+54
View File
@@ -0,0 +1,54 @@
package audio
import (
"fmt"
"os"
"time"
"github.com/tcolgate/mp3"
)
func analyzeMP3(filePath string) (*TrackInfo, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("opening MP3: %w", err)
}
defer f.Close()
decoder := mp3.NewDecoder(f)
var frame mp3.Frame
var skipped int
var totalDuration time.Duration
var sampleRate, channels, bitrate int
var frameCount int
for {
err := decoder.Decode(&frame, &skipped)
if err != nil {
break
}
if frameCount == 0 {
sampleRate = int(frame.Header().SampleRate())
channels = channelCount(frame.Header().ChannelMode())
bitrate = int(frame.Header().BitRate()) / 1000
}
totalDuration += frame.Duration()
frameCount++
}
return &TrackInfo{
Format: "MP3",
SampleRate: sampleRate,
Channels: channels,
DurationMs: int(totalDuration.Milliseconds()),
BitrateKbps: bitrate,
IsLossless: false,
}, nil
}
func channelCount(mode mp3.FrameChannelMode) int {
if mode == mp3.SingleChannel {
return 1
}
return 2
}
+77
View File
@@ -0,0 +1,77 @@
package config
import (
"fmt"
"time"
)
const (
IndexerTypeJackett IndexerType = "jackett"
)
const (
TorrentClientQbittorrent TorrentClientType = "qbittorrent"
)
type IndexerType string
type TorrentClientType string
type Config struct {
App struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
} `yaml:"app"`
Database struct {
URL string `yaml:"url"`
} `yaml:"database"`
Indexer struct {
Url string `yaml:"url"`
Port string `yaml:"port"`
Type IndexerType `yaml:"type"`
ApiKey string `yaml:"api_key"`
Cache CacheConfig `yaml:"cache"`
} `yaml:"indexer"`
Torrent struct {
ClientType TorrentClientType `yaml:"client_type"`
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
ContainerName string `yaml:"container_name"`
} `yaml:"torrent"`
Metadata struct {
Endpoint string `yaml:"endpoint"`
} `yaml:"metadata"`
}
type CacheConfig struct {
Enabled bool `yaml:"enabled"`
RefreshInterval time.Duration `yaml:"refresh_interval"`
TTL time.Duration `yaml:"ttl"`
}
func (t *IndexerType) UnmarshalYAML(unmarshal func(any) error) error {
var value string
if err := unmarshal(&value); err != nil {
return err
}
switch IndexerType(value) {
case IndexerTypeJackett:
*t = IndexerType(value)
return nil
default:
return fmt.Errorf("unknown indexer type: %s", value)
}
}
func NewConfig() *Config {
config := Config{}
config.App.Host = "localhost"
config.App.Port = "8080"
return &config
}
+116
View File
@@ -0,0 +1,116 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type AlbumEvent struct {
ID string
Seq int64
WorkflowRunID string
AlbumID string
EventType string
Step string
Message string
DataJSON []byte
CreatedAt time.Time
}
type AlbumEventRepository struct {
pool *pgxpool.Pool
}
func NewAlbumEventRepository(pool *pgxpool.Pool) *AlbumEventRepository {
return &AlbumEventRepository{pool: pool}
}
func (r *AlbumEventRepository) Create(ctx context.Context, event *AlbumEvent) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO album_events (workflow_run_id, album_id, event_type, step, message, data_json)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, seq, created_at`,
event.WorkflowRunID, event.AlbumID, event.EventType, event.Step, event.Message, event.DataJSON,
).Scan(&event.ID, &event.Seq, &event.CreatedAt)
if err != nil {
return fmt.Errorf("creating album event: %w", err)
}
return nil
}
func (r *AlbumEventRepository) GetByWorkflowRun(ctx context.Context, workflowRunID string) ([]*AlbumEvent, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
FROM album_events WHERE workflow_run_id = $1 ORDER BY seq`, workflowRunID,
)
if err != nil {
return nil, fmt.Errorf("listing album events by workflow run: %w", err)
}
defer rows.Close()
var events []*AlbumEvent
for rows.Next() {
event := &AlbumEvent{}
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album event: %w", err)
}
events = append(events, event)
}
return events, nil
}
func (r *AlbumEventRepository) GetByAlbum(ctx context.Context, albumID string, afterSeq int64, limit int) ([]*AlbumEvent, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
FROM album_events WHERE album_id = $1 AND seq > $2 ORDER BY seq LIMIT $3`, albumID, afterSeq, limit,
)
if err != nil {
return nil, fmt.Errorf("listing album events by album: %w", err)
}
defer rows.Close()
var events []*AlbumEvent
for rows.Next() {
event := &AlbumEvent{}
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album event: %w", err)
}
events = append(events, event)
}
return events, nil
}
func (r *AlbumEventRepository) GetLatestSeq(ctx context.Context) (int64, error) {
var seq int64
err := r.pool.QueryRow(ctx,
`SELECT COALESCE(MAX(seq), 0) FROM album_events`,
).Scan(&seq)
if err != nil {
return 0, fmt.Errorf("getting latest album event seq: %w", err)
}
return seq, nil
}
func (r *AlbumEventRepository) GetAfterSeq(ctx context.Context, afterSeq int64) ([]*AlbumEvent, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
FROM album_events WHERE seq > $1 ORDER BY seq LIMIT 1000`, afterSeq,
)
if err != nil {
return nil, fmt.Errorf("listing album events after seq: %w", err)
}
defer rows.Close()
var events []*AlbumEvent
for rows.Next() {
event := &AlbumEvent{}
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album event: %w", err)
}
events = append(events, event)
}
return events, nil
}
@@ -0,0 +1,91 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type AlbumRelease struct {
ID string
AlbumID string
DownloadID string
Format string
BitDepth *int
SampleRate *int
Channels int
IsLossless bool
Source *string
TotalSize int64
TotalDurationMs int
TrackCount int
HasCoverArt bool
HasCueSheet bool
HasRipLog bool
Path string
CreatedAt time.Time
}
type AlbumReleaseRepository struct {
pool *pgxpool.Pool
}
func NewAlbumReleaseRepository(pool *pgxpool.Pool) *AlbumReleaseRepository {
return &AlbumReleaseRepository{pool: pool}
}
func (r *AlbumReleaseRepository) Create(ctx context.Context, ar *AlbumRelease) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO album_releases (album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, created_at`,
ar.AlbumID, ar.DownloadID, ar.Format, ar.BitDepth, ar.SampleRate, ar.Channels, ar.IsLossless, ar.Source, ar.TotalSize, ar.TotalDurationMs, ar.TrackCount, ar.HasCoverArt, ar.HasCueSheet, ar.HasRipLog, ar.Path,
).Scan(&ar.ID, &ar.CreatedAt)
if err != nil {
return fmt.Errorf("creating album release: %w", err)
}
return nil
}
func (r *AlbumReleaseRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*AlbumRelease, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
FROM album_releases WHERE album_id = $1 ORDER BY created_at DESC`, albumID,
)
if err != nil {
return nil, fmt.Errorf("listing album releases: %w", err)
}
defer rows.Close()
var releases []*AlbumRelease
for rows.Next() {
ar := &AlbumRelease{}
if err := rows.Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album release: %w", err)
}
releases = append(releases, ar)
}
return releases, nil
}
func (r *AlbumReleaseRepository) GetByDownloadID(ctx context.Context, downloadID string) (*AlbumRelease, error) {
ar := &AlbumRelease{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
FROM album_releases WHERE download_id = $1`, downloadID,
).Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt)
if err != nil {
return nil, fmt.Errorf("getting album release by download: %w", err)
}
return ar, nil
}
func (r *AlbumReleaseRepository) DeleteByDownloadID(ctx context.Context, downloadID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM album_releases WHERE download_id = $1`, downloadID)
if err != nil {
return fmt.Errorf("deleting album release by download: %w", err)
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Album struct {
ID string
ExternalID string
ArtistID string
Title string
AlbumType string
ReleaseDate *time.Time
TotalTracks int
TotalDiscs int
Label string
Genres []string
CoverURL string
MonitorState MonitorState
CreatedAt time.Time
UpdatedAt time.Time
}
type AlbumRepository struct {
pool *pgxpool.Pool
}
func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
return &AlbumRepository{pool: pool}
}
func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO albums (external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (external_id) DO UPDATE SET
title = EXCLUDED.title,
album_type = EXCLUDED.album_type,
release_date = EXCLUDED.release_date,
total_tracks = EXCLUDED.total_tracks,
total_discs = EXCLUDED.total_discs,
label = EXCLUDED.label,
genres = EXCLUDED.genres,
cover_url = EXCLUDED.cover_url,
monitor_state = CASE
WHEN albums.monitor_state = 'excluded' THEN albums.monitor_state
WHEN albums.monitor_state = 'monitored' THEN albums.monitor_state
ELSE EXCLUDED.monitor_state
END,
updated_at = NOW()`,
a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.MonitorState,
)
if err != nil {
return fmt.Errorf("creating album: %w", err)
}
return nil
}
func (r *AlbumRepository) CreateBatch(ctx context.Context, albums []*Album) error {
if len(albums) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, a := range albums {
batch.Queue(
`INSERT INTO albums (external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (external_id) DO UPDATE SET
title = EXCLUDED.title,
album_type = EXCLUDED.album_type,
release_date = EXCLUDED.release_date,
total_tracks = EXCLUDED.total_tracks,
total_discs = EXCLUDED.total_discs,
label = EXCLUDED.label,
genres = EXCLUDED.genres,
cover_url = EXCLUDED.cover_url,
monitor_state = CASE
WHEN albums.monitor_state = 'excluded' THEN albums.monitor_state
WHEN albums.monitor_state = 'monitored' THEN albums.monitor_state
ELSE EXCLUDED.monitor_state
END,
updated_at = NOW()`,
a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.MonitorState,
)
}
results := r.pool.SendBatch(ctx, batch)
defer results.Close()
for range albums {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("batch creating album: %w", err)
}
}
return nil
}
func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string) (*Album, error) {
a := &Album{}
err := r.pool.QueryRow(ctx,
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
FROM albums WHERE external_id = $1`, externalID,
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting album: %w", err)
}
return a, nil
}
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error) {
a := &Album{}
err := r.pool.QueryRow(ctx,
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
FROM albums WHERE id = $1`, id,
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting album: %w", err)
}
return a, nil
}
func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([]*Album, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
FROM albums WHERE artist_id = $1 ORDER BY release_date DESC`, artistID,
)
if err != nil {
return nil, fmt.Errorf("listing albums: %w", err)
}
defer rows.Close()
var albums []*Album
for rows.Next() {
a := &Album{}
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning album: %w", err)
}
albums = append(albums, a)
}
return albums, nil
}
func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
FROM albums WHERE monitor_state = 'monitored' ORDER BY release_date DESC`,
)
if err != nil {
return nil, fmt.Errorf("listing monitored albums: %w", err)
}
defer rows.Close()
var albums []*Album
for rows.Next() {
a := &Album{}
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning album: %w", err)
}
albums = append(albums, a)
}
return albums, nil
}
func (r *AlbumRepository) SetMonitorState(ctx context.Context, id string, state MonitorState) error {
_, err := r.pool.Exec(ctx,
`UPDATE albums SET monitor_state = $1, updated_at = NOW() WHERE id = $2`, state, id,
)
if err != nil {
return fmt.Errorf("updating monitor state: %w", err)
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Artist struct {
ID string
ExternalID string
Name string
ArtistType string
Country string
Genres []string
ImageURL string
MonitorState MonitorState
CreatedAt time.Time
UpdatedAt time.Time
}
type ArtistRepository struct {
pool *pgxpool.Pool
}
func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository {
return &ArtistRepository{pool: pool}
}
func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (external_id) DO UPDATE SET
name = EXCLUDED.name,
artist_type = EXCLUDED.artist_type,
country = EXCLUDED.country,
genres = EXCLUDED.genres,
image_url = EXCLUDED.image_url,
monitor_state = CASE
WHEN artists.monitor_state = 'excluded' THEN artists.monitor_state
WHEN artists.monitor_state = 'monitored' THEN artists.monitor_state
ELSE EXCLUDED.monitor_state
END,
updated_at = NOW()
RETURNING id, created_at, updated_at`,
a.ExternalID, a.Name, a.ArtistType, a.Country, a.Genres, a.ImageURL, a.MonitorState,
)
if err != nil {
return fmt.Errorf("creating artist: %w", err)
}
return nil
}
func (r *ArtistRepository) GetByExternalID(ctx context.Context, externalID string) (*Artist, error) {
a := &Artist{}
err := r.pool.QueryRow(ctx,
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
FROM artists WHERE external_id = $1`, externalID,
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting artist: %w", err)
}
return a, nil
}
func (r *ArtistRepository) GetAll(ctx context.Context) ([]*Artist, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
FROM artists ORDER BY name ASC`,
)
if err != nil {
return nil, fmt.Errorf("listing artists: %w", err)
}
defer rows.Close()
var artists []*Artist
for rows.Next() {
a := &Artist{}
if err := rows.Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning artist: %w", err)
}
artists = append(artists, a)
}
return artists, nil
}
func (r *ArtistRepository) GetByID(ctx context.Context, id string) (*Artist, error) {
a := &Artist{}
err := r.pool.QueryRow(ctx,
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
FROM artists WHERE id = $1`, id,
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting artist: %w", err)
}
return a, nil
}
+40
View File
@@ -0,0 +1,40 @@
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
type MonitorState string
const (
Monitored MonitorState = "monitored"
Unmonitored MonitorState = "unmonitored"
Excluded MonitorState = "excluded"
)
type DB struct {
Pool *pgxpool.Pool
}
func New(ctx context.Context, databaseURL string) (*DB, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("connecting to database: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
log.Info().Str("url", databaseURL).Msg("database connected")
return &DB{Pool: pool}, nil
}
func (db *DB) Close() {
db.Pool.Close()
}
@@ -0,0 +1,104 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DownloadFile struct {
ID string
DownloadID string
TrackID *string
FilePath string
FileSize int64
FileType string
SHA256Hash string
VerifiedAt *time.Time
CreatedAt time.Time
}
type DownloadFileRepository struct {
pool *pgxpool.Pool
}
func NewDownloadFileRepository(pool *pgxpool.Pool) *DownloadFileRepository {
return &DownloadFileRepository{pool: pool}
}
func (r *DownloadFileRepository) Create(ctx context.Context, f *DownloadFile) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO download_files (download_id, track_id, file_path, file_size, file_type, sha256_hash)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at`,
f.DownloadID, f.TrackID, f.FilePath, f.FileSize, f.FileType, f.SHA256Hash,
).Scan(&f.ID, &f.CreatedAt)
if err != nil {
return fmt.Errorf("creating download file: %w", err)
}
return nil
}
func (r *DownloadFileRepository) CreateBatch(ctx context.Context, files []*DownloadFile) error {
for _, f := range files {
if err := r.Create(ctx, f); err != nil {
return err
}
}
return nil
}
func (r *DownloadFileRepository) GetByDownloadID(ctx context.Context, downloadID string) ([]*DownloadFile, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, download_id, track_id, file_path, file_size, file_type, sha256_hash, verified_at, created_at
FROM download_files WHERE download_id = $1 ORDER BY file_path`, downloadID,
)
if err != nil {
return nil, fmt.Errorf("listing download files: %w", err)
}
defer rows.Close()
var files []*DownloadFile
for rows.Next() {
f := &DownloadFile{}
if err := rows.Scan(&f.ID, &f.DownloadID, &f.TrackID, &f.FilePath, &f.FileSize, &f.FileType, &f.SHA256Hash, &f.VerifiedAt, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning download file: %w", err)
}
files = append(files, f)
}
return files, nil
}
func (r *DownloadFileRepository) SetHash(ctx context.Context, id string, hash string) error {
_, err := r.pool.Exec(ctx,
`UPDATE download_files SET sha256_hash = $1, verified_at = NOW() WHERE id = $2`, hash, id,
)
if err != nil {
return fmt.Errorf("setting file hash: %w", err)
}
return nil
}
func (r *DownloadFileRepository) GetUnverified(ctx context.Context) ([]*DownloadFile, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, download_id, track_id, file_path, file_size, file_type, sha256_hash, verified_at, created_at
FROM download_files WHERE sha256_hash IS NULL OR verified_at < NOW() - INTERVAL '30 days'
ORDER BY created_at`,
)
if err != nil {
return nil, fmt.Errorf("listing unverified files: %w", err)
}
defer rows.Close()
var files []*DownloadFile
for rows.Next() {
f := &DownloadFile{}
if err := rows.Scan(&f.ID, &f.DownloadID, &f.TrackID, &f.FilePath, &f.FileSize, &f.FileType, &f.SHA256Hash, &f.VerifiedAt, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning download file: %w", err)
}
files = append(files, f)
}
return files, nil
}
+213
View File
@@ -0,0 +1,213 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Download struct {
ID string
TorrentID string
AlbumID string
Format string
Quality string
State string
QbitHash string
SavePath *string
ErrorMessage *string
QueuedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type DownloadRepository struct {
pool *pgxpool.Pool
}
func NewDownloadRepository(pool *pgxpool.Pool) *DownloadRepository {
return &DownloadRepository{pool: pool}
}
func (r *DownloadRepository) Create(ctx context.Context, d *Download) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash, save_path)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, queued_at, created_at, updated_at`,
d.TorrentID, d.AlbumID, d.Format, d.Quality, d.State, d.QbitHash, d.SavePath,
).Scan(&d.ID, &d.QueuedAt, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
return fmt.Errorf("creating download: %w", err)
}
return nil
}
func (r *DownloadRepository) UpdateState(ctx context.Context, id string, state string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = $1, updated_at = NOW() WHERE id = $2`, state, id,
)
if err != nil {
return fmt.Errorf("updating download state: %w", err)
}
return nil
}
func (r *DownloadRepository) SetStarted(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = 'downloading', started_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("setting download started: %w", err)
}
return nil
}
func (r *DownloadRepository) SetCompleted(ctx context.Context, id string, savePath string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = 'completed', save_path = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2`, savePath, id,
)
if err != nil {
return fmt.Errorf("setting download completed: %w", err)
}
return nil
}
func (r *DownloadRepository) SetFailed(ctx context.Context, id string, errorMsg string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = 'failed', error_message = $1, updated_at = NOW() WHERE id = $2`, errorMsg, id,
)
if err != nil {
return fmt.Errorf("setting download failed: %w", err)
}
return nil
}
func (r *DownloadRepository) SetCancelled(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = 'cancelled', updated_at = NOW() WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("setting download cancelled: %w", err)
}
return nil
}
func (r *DownloadRepository) SetCancelledByQbitHash(ctx context.Context, hash string) error {
_, err := r.pool.Exec(ctx,
`UPDATE downloads SET state = 'cancelled', updated_at = NOW() WHERE qbit_hash = $1 AND state NOT IN ('completed', 'failed', 'cancelled')`, hash,
)
if err != nil {
return fmt.Errorf("setting download cancelled by hash: %w", err)
}
return nil
}
func (r *DownloadRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Download, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
FROM downloads WHERE album_id = $1 ORDER BY created_at DESC`, albumID,
)
if err != nil {
return nil, fmt.Errorf("listing downloads: %w", err)
}
defer rows.Close()
var downloads []*Download
for rows.Next() {
d := &Download{}
if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning download: %w", err)
}
downloads = append(downloads, d)
}
return downloads, nil
}
func (r *DownloadRepository) GetActiveByTorrentID(ctx context.Context, torrentID string) (*Download, error) {
d := &Download{}
err := r.pool.QueryRow(ctx,
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
FROM downloads WHERE torrent_id = $1 AND state NOT IN ('failed')
ORDER BY created_at DESC LIMIT 1`, torrentID,
).Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting active download by torrent: %w", err)
}
return d, nil
}
func (r *DownloadRepository) GetActive(ctx context.Context) ([]*Download, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
FROM downloads WHERE state IN ('pending', 'downloading') ORDER BY created_at`,
)
if err != nil {
return nil, fmt.Errorf("listing active downloads: %w", err)
}
defer rows.Close()
var downloads []*Download
for rows.Next() {
d := &Download{}
if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning download: %w", err)
}
downloads = append(downloads, d)
}
return downloads, nil
}
func (r *DownloadRepository) GetByID(ctx context.Context, id string) (*Download, error) {
d := &Download{}
err := r.pool.QueryRow(ctx,
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
FROM downloads WHERE id = $1`, id,
).Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting download by id: %w", err)
}
return d, nil
}
func (r *DownloadRepository) GetLatestByAlbumIDs(ctx context.Context, albumIDs []string) (map[string]*Download, error) {
if len(albumIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx,
`SELECT DISTINCT ON (album_id) id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
FROM downloads WHERE album_id = ANY($1) ORDER BY album_id, created_at DESC`, albumIDs,
)
if err != nil {
return nil, fmt.Errorf("batch listing downloads: %w", err)
}
defer rows.Close()
result := make(map[string]*Download, len(albumIDs))
for rows.Next() {
d := &Download{}
if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning download: %w", err)
}
result[d.AlbumID] = d
}
return result, nil
}
func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS(
SELECT 1 FROM downloads
WHERE album_id = $1 AND format = $2 AND quality = $3 AND state IN ('completed', 'seeding')
)`, albumID, format, quality,
).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking album quality: %w", err)
}
return exists, nil
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Torrent struct {
ID string
AlbumID string
InfoHash string
Tracker string
Title string
Format string
Quality string
Source string
BitDepth int
SampleRate int
Seeders int
Peers int
Size int64
TrackCount int
HasCoverArt bool
HasCueSheet bool
HasRipLog bool
DownloadLink string
TorrentFile []byte
CreatedAt time.Time
UpdatedAt time.Time
}
type TorrentRepository struct {
pool *pgxpool.Pool
}
func NewTorrentRepository(pool *pgxpool.Pool) *TorrentRepository {
return &TorrentRepository{pool: pool}
}
func (r *TorrentRepository) Create(ctx context.Context, t *Torrent) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (info_hash) DO UPDATE SET
seeders = EXCLUDED.seeders,
peers = EXCLUDED.peers,
updated_at = NOW()`,
t.AlbumID, t.InfoHash, t.Tracker, t.Title, t.Format, t.Quality, t.Source, t.BitDepth, t.SampleRate, t.Seeders, t.Peers, t.Size, t.TrackCount, t.HasCoverArt, t.HasCueSheet, t.HasRipLog, t.DownloadLink, t.TorrentFile,
)
if err != nil {
return fmt.Errorf("creating torrent: %w", err)
}
return nil
}
func (r *TorrentRepository) GetByInfoHash(ctx context.Context, infoHash string) (*Torrent, error) {
t := &Torrent{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file, created_at, updated_at
FROM torrents WHERE info_hash = $1`, infoHash,
).Scan(&t.ID, &t.AlbumID, &t.InfoHash, &t.Tracker, &t.Title, &t.Format, &t.Quality, &t.Source, &t.BitDepth, &t.SampleRate, &t.Seeders, &t.Peers, &t.Size, &t.TrackCount, &t.HasCoverArt, &t.HasCueSheet, &t.HasRipLog, &t.DownloadLink, &t.TorrentFile, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting torrent: %w", err)
}
return t, nil
}
func (r *TorrentRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Torrent, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file, created_at, updated_at
FROM torrents WHERE album_id = $1 ORDER BY seeders DESC`, albumID,
)
if err != nil {
return nil, fmt.Errorf("listing torrents: %w", err)
}
defer rows.Close()
var torrents []*Torrent
for rows.Next() {
t := &Torrent{}
if err := rows.Scan(&t.ID, &t.AlbumID, &t.InfoHash, &t.Tracker, &t.Title, &t.Format, &t.Quality, &t.Source, &t.BitDepth, &t.SampleRate, &t.Seeders, &t.Peers, &t.Size, &t.TrackCount, &t.HasCoverArt, &t.HasCueSheet, &t.HasRipLog, &t.DownloadLink, &t.TorrentFile, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning torrent: %w", err)
}
torrents = append(torrents, t)
}
return torrents, nil
}
func (r *TorrentRepository) HasAlbumInFormat(ctx context.Context, albumID string, format string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS(
SELECT 1 FROM downloads
WHERE album_id = $1 AND format = $2 AND state IN ('completed', 'seeding')
)`, albumID, format,
).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking album format: %w", err)
}
return exists, nil
}
@@ -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
}
+68
View File
@@ -0,0 +1,68 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Track struct {
ID string
ExternalID string
AlbumID string
Title string
DurationMS int
ISRC string
DiscNumber int
TrackNumber int
CreatedAt time.Time
}
type TrackRepository struct {
pool *pgxpool.Pool
}
func NewTrackRepository(pool *pgxpool.Pool) *TrackRepository {
return &TrackRepository{pool: pool}
}
func (r *TrackRepository) Create(ctx context.Context, t *Track) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO tracks (external_id, album_id, title, duration_ms, isrc, disc_number, track_number)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (external_id) DO UPDATE SET
title = EXCLUDED.title,
duration_ms = EXCLUDED.duration_ms,
isrc = EXCLUDED.isrc,
disc_number = EXCLUDED.disc_number,
track_number = EXCLUDED.track_number`,
t.ExternalID, t.AlbumID, t.Title, t.DurationMS, t.ISRC, t.DiscNumber, t.TrackNumber,
)
if err != nil {
return fmt.Errorf("creating track: %w", err)
}
return nil
}
func (r *TrackRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Track, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, external_id, album_id, title, duration_ms, isrc, disc_number, track_number, created_at
FROM tracks WHERE album_id = $1 ORDER BY disc_number, track_number`, albumID,
)
if err != nil {
return nil, fmt.Errorf("listing tracks: %w", err)
}
defer rows.Close()
var tracks []*Track
for rows.Next() {
t := &Track{}
if err := rows.Scan(&t.ID, &t.ExternalID, &t.AlbumID, &t.Title, &t.DurationMS, &t.ISRC, &t.DiscNumber, &t.TrackNumber, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning track: %w", err)
}
tracks = append(tracks, t)
}
return tracks, nil
}
@@ -0,0 +1,123 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrWorkflowAlreadyRunning = fmt.Errorf("workflow already running for this album and quality")
type WorkflowRun struct {
ID string
AlbumID string
Quality string
Status string
ErrorMessage *string
StartedAt time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type WorkflowRunRepository struct {
pool *pgxpool.Pool
}
func NewWorkflowRunRepository(pool *pgxpool.Pool) *WorkflowRunRepository {
return &WorkflowRunRepository{pool: pool}
}
func (r *WorkflowRunRepository) Create(ctx context.Context, run *WorkflowRun) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO workflow_runs (album_id, quality, status) VALUES ($1, $2, 'running')
ON CONFLICT ON CONSTRAINT idx_workflow_runs_active DO NOTHING
RETURNING id, started_at, created_at, updated_at`,
run.AlbumID, run.Quality,
).Scan(&run.ID, &run.StartedAt, &run.CreatedAt, &run.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return ErrWorkflowAlreadyRunning
}
return fmt.Errorf("creating workflow run: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) SetCompleted(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`UPDATE workflow_runs SET status = 'completed', completed_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("setting workflow run completed: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) SetFailed(ctx context.Context, id string, errorMsg string) error {
_, err := r.pool.Exec(ctx,
`UPDATE workflow_runs SET status = 'failed', error_message = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2`, errorMsg, id,
)
if err != nil {
return fmt.Errorf("setting workflow run failed: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) SetCancelled(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`UPDATE workflow_runs SET status = 'cancelled', completed_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("setting workflow run cancelled: %w", err)
}
return nil
}
func (r *WorkflowRunRepository) GetByAlbumAndQuality(ctx context.Context, albumID string, quality string) (*WorkflowRun, error) {
run := &WorkflowRun{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
FROM workflow_runs WHERE album_id = $1 AND quality = $2 AND status = 'running' LIMIT 1`, albumID, quality,
).Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting workflow run by album and quality: %w", err)
}
return run, nil
}
func (r *WorkflowRunRepository) GetRunning(ctx context.Context) ([]*WorkflowRun, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
FROM workflow_runs WHERE status = 'running' ORDER BY started_at`,
)
if err != nil {
return nil, fmt.Errorf("listing running workflow runs: %w", err)
}
defer rows.Close()
var runs []*WorkflowRun
for rows.Next() {
run := &WorkflowRun{}
if err := rows.Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning workflow run: %w", err)
}
runs = append(runs, run)
}
return runs, nil
}
func (r *WorkflowRunRepository) GetByID(ctx context.Context, id string) (*WorkflowRun, error) {
run := &WorkflowRun{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
FROM workflow_runs WHERE id = $1`, id,
).Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting workflow run by id: %w", err)
}
return run, nil
}
+260
View File
@@ -0,0 +1,260 @@
package internal
import (
"context"
"encoding/json"
"sync"
"github.com/rs/zerolog/log"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/eventbus"
)
type EventPublisher interface {
PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error
PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error
PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error
SetAlbumID(albumID string)
SetWorkflowRunID(id string)
}
type dbEventPublisher struct {
mu sync.Mutex
workflowRunID string
albumID string
quality string
events *database.AlbumEventRepository
bus *eventbus.EventBus
topic string
}
func newDBEventPublisher(albumID, quality string, events *database.AlbumEventRepository, bus *eventbus.EventBus, topic string) *dbEventPublisher {
return &dbEventPublisher{
albumID: albumID,
quality: quality,
events: events,
bus: bus,
topic: topic,
}
}
func (p *dbEventPublisher) SetAlbumID(albumID string) {
p.mu.Lock()
defer p.mu.Unlock()
p.albumID = albumID
}
func (p *dbEventPublisher) getAlbumID() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.albumID
}
func (p *dbEventPublisher) SetWorkflowRunID(id string) {
p.mu.Lock()
defer p.mu.Unlock()
p.workflowRunID = id
}
func (p *dbEventPublisher) getWorkflowRunID() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.workflowRunID
}
func (p *dbEventPublisher) PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error {
var dataJSON []byte
if data != nil {
var err error
dataJSON, err = json.Marshal(data)
if err != nil {
log.Warn().Err(err).Msg("failed to marshal status data to JSON")
dataJSON = nil
}
}
albumID := p.getAlbumID()
workflowRunID := p.getWorkflowRunID()
var seq int64
if albumID != "" {
event := &database.AlbumEvent{
WorkflowRunID: workflowRunID,
AlbumID: albumID,
EventType: "status",
Step: step.String(),
Message: msg,
DataJSON: dataJSON,
}
if err := p.events.Create(ctx, event); err != nil {
log.Error().Err(err).Msg("failed to persist status event")
} else {
seq = event.Seq
}
}
p.bus.Publish(p.topic, &eventbus.Event{
Seq: seq,
WorkflowRunID: workflowRunID,
AlbumID: albumID,
Quality: p.quality,
EventType: "status",
Step: step.String(),
Message: msg,
Data: data,
})
return nil
}
func (p *dbEventPublisher) PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error {
albumID := p.getAlbumID()
workflowRunID := p.getWorkflowRunID()
var seq int64
if albumID != "" {
event := &database.AlbumEvent{
WorkflowRunID: workflowRunID,
AlbumID: albumID,
EventType: "error",
Step: step.String(),
Message: err.Error(),
}
if dbErr := p.events.Create(ctx, event); dbErr != nil {
log.Error().Err(dbErr).Msg("failed to persist error event")
} else {
seq = event.Seq
}
}
p.bus.Publish(p.topic, &eventbus.Event{
Seq: seq,
WorkflowRunID: workflowRunID,
AlbumID: albumID,
Quality: p.quality,
EventType: "error",
Step: step.String(),
Message: err.Error(),
Data: map[string]bool{"recoverable": recoverable},
})
return nil
}
func (p *dbEventPublisher) PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error {
var dataJSON []byte
if result != nil {
var err error
dataJSON, err = json.Marshal(result)
if err != nil {
log.Warn().Err(err).Msg("failed to marshal result to JSON")
dataJSON = nil
}
}
albumID := p.getAlbumID()
workflowRunID := p.getWorkflowRunID()
var seq int64
if albumID != "" {
event := &database.AlbumEvent{
WorkflowRunID: workflowRunID,
AlbumID: albumID,
EventType: "result",
Step: pb.MonitorStep_MONITOR_STEP_COMPLETE.String(),
Message: "workflow completed",
DataJSON: dataJSON,
}
if err := p.events.Create(ctx, event); err != nil {
log.Error().Err(err).Msg("failed to persist result event")
} else {
seq = event.Seq
}
}
p.bus.Publish(p.topic, &eventbus.Event{
Seq: seq,
WorkflowRunID: workflowRunID,
AlbumID: albumID,
Quality: p.quality,
EventType: "result",
Step: pb.MonitorStep_MONITOR_STEP_COMPLETE.String(),
Message: "workflow completed",
Data: result,
})
return nil
}
type streamEventPublisher struct {
*dbEventPublisher
stream pb.MusicAgregatorService_MonitorAlbumStreamServer
}
func newStreamEventPublisher(db *dbEventPublisher, stream pb.MusicAgregatorService_MonitorAlbumStreamServer) *streamEventPublisher {
return &streamEventPublisher{
dbEventPublisher: db,
stream: stream,
}
}
func (p *streamEventPublisher) PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error {
if err := p.dbEventPublisher.PublishStatus(ctx, step, msg, data); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
status := &pb.StatusUpdate{
Step: step,
Message: msg,
}
switch v := data.(type) {
case *pb.StreamAlbumInfo:
status.Data = &pb.StatusUpdate_AlbumInfo{AlbumInfo: v}
case *pb.TorrentList:
status.Data = &pb.StatusUpdate_Torrents{Torrents: v}
case *pb.ReleaseInfo:
status.Data = &pb.StatusUpdate_ReleaseInfo{ReleaseInfo: v}
}
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
Message: &pb.MonitorAlbumStreamResponse_Status{Status: status},
})
}
func (p *streamEventPublisher) PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error {
if dbErr := p.dbEventPublisher.PublishError(ctx, step, err, recoverable); dbErr != nil {
return dbErr
}
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
Message: &pb.MonitorAlbumStreamResponse_Error{
Error: &pb.ErrorUpdate{
FailedStep: step,
Message: err.Error(),
Recoverable: recoverable,
},
},
})
}
func (p *streamEventPublisher) PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error {
if err := p.dbEventPublisher.PublishResult(ctx, result); err != nil {
return err
}
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
Message: &pb.MonitorAlbumStreamResponse_Result{Result: result},
})
}
+116
View File
@@ -0,0 +1,116 @@
package eventbus
import "sync"
type Event struct {
Seq int64
WorkflowRunID string
AlbumID string
Quality string
EventType string
Step string
Message string
Data interface{}
}
type Subscription struct {
Ring *RingBuffer[*Event]
C chan struct{}
done chan struct{}
once sync.Once
}
type EventBus struct {
mu sync.RWMutex
topics map[string]map[*Subscription]struct{}
global map[*Subscription]struct{}
}
func New() *EventBus {
return &EventBus{
topics: make(map[string]map[*Subscription]struct{}),
global: make(map[*Subscription]struct{}),
}
}
func (b *EventBus) Publish(topic string, event *Event) {
b.mu.RLock()
defer b.mu.RUnlock()
if subs, ok := b.topics[topic]; ok {
for sub := range subs {
sub.Ring.Push(event)
select {
case sub.C <- struct{}{}:
default:
}
}
}
for sub := range b.global {
sub.Ring.Push(event)
select {
case sub.C <- struct{}{}:
default:
}
}
}
func (b *EventBus) Subscribe(topic string) (*Subscription, func()) {
sub := &Subscription{
Ring: NewRingBuffer[*Event](256),
C: make(chan struct{}, 1),
done: make(chan struct{}),
}
b.mu.Lock()
if b.topics[topic] == nil {
b.topics[topic] = make(map[*Subscription]struct{})
}
b.topics[topic][sub] = struct{}{}
b.mu.Unlock()
cleanup := func() {
sub.once.Do(func() {
b.mu.Lock()
delete(b.topics[topic], sub)
if len(b.topics[topic]) == 0 {
delete(b.topics, topic)
}
b.mu.Unlock()
close(sub.done)
})
}
return sub, cleanup
}
func (b *EventBus) SubscribeGlobal() (*Subscription, func()) {
sub := &Subscription{
Ring: NewRingBuffer[*Event](256),
C: make(chan struct{}, 1),
done: make(chan struct{}),
}
b.mu.Lock()
b.global[sub] = struct{}{}
b.mu.Unlock()
cleanup := func() {
sub.once.Do(func() {
b.mu.Lock()
delete(b.global, sub)
b.mu.Unlock()
close(sub.done)
})
}
return sub, cleanup
}
func (b *EventBus) HasTopic(topic string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
_, ok := b.topics[topic]
return ok
}
+168
View File
@@ -0,0 +1,168 @@
package eventbus
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEventBus_PublishSubscribe(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("test-topic")
defer cleanup()
event := &Event{Seq: 1, EventType: "status", Message: "hello"}
bus.Publish("test-topic", event)
got, ok := sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got)
}
func TestEventBus_MultipleSubscribers(t *testing.T) {
bus := New()
sub1, cleanup1 := bus.Subscribe("topic")
defer cleanup1()
sub2, cleanup2 := bus.Subscribe("topic")
defer cleanup2()
sub3, cleanup3 := bus.Subscribe("topic")
defer cleanup3()
event := &Event{Seq: 1, EventType: "status"}
bus.Publish("topic", event)
got1, ok := sub1.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got1)
got2, ok := sub2.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got2)
got3, ok := sub3.Ring.Pop()
require.True(t, ok)
assert.Equal(t, event, got3)
}
func TestEventBus_GlobalSubscriber(t *testing.T) {
bus := New()
sub, cleanup := bus.SubscribeGlobal()
defer cleanup()
bus.Publish("topic-a", &Event{Seq: 1})
bus.Publish("topic-b", &Event{Seq: 2})
bus.Publish("topic-c", &Event{Seq: 3})
got, ok := sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(1), got.Seq)
got, ok = sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(2), got.Seq)
got, ok = sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(3), got.Seq)
}
func TestEventBus_TopicIsolation(t *testing.T) {
bus := New()
subA, cleanupA := bus.Subscribe("topic-a")
defer cleanupA()
bus.Publish("topic-b", &Event{Seq: 1})
_, ok := subA.Ring.Pop()
assert.False(t, ok)
}
func TestEventBus_Notification(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("topic")
defer cleanup()
bus.Publish("topic", &Event{Seq: 1})
select {
case <-sub.C:
case <-time.After(100 * time.Millisecond):
t.Fatal("expected notification on channel")
}
}
func TestEventBus_Unsubscribe(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("topic")
bus.Publish("topic", &Event{Seq: 1})
_, ok := sub.Ring.Pop()
require.True(t, ok)
cleanup()
bus.Publish("topic", &Event{Seq: 2})
_, ok = sub.Ring.Pop()
assert.False(t, ok)
}
func TestEventBus_SlowSubscriber(t *testing.T) {
bus := New()
sub, cleanup := bus.Subscribe("topic")
defer cleanup()
for i := 0; i < 500; i++ {
bus.Publish("topic", &Event{Seq: int64(i)})
}
assert.Equal(t, 256, sub.Ring.Len())
first, ok := sub.Ring.Pop()
require.True(t, ok)
assert.Equal(t, int64(244), first.Seq)
}
func TestEventBus_HasTopic(t *testing.T) {
bus := New()
assert.False(t, bus.HasTopic("topic"))
sub, cleanup := bus.Subscribe("topic")
_ = sub
assert.True(t, bus.HasTopic("topic"))
cleanup()
assert.False(t, bus.HasTopic("topic"))
}
func TestEventBus_ConcurrentPublishSubscribe(t *testing.T) {
bus := New()
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
sub, cleanup := bus.Subscribe("topic")
defer cleanup()
for j := 0; j < 100; j++ {
sub.Ring.Pop()
}
}(i)
}
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
bus.Publish("topic", &Event{Seq: int64(id*100 + j)})
}
}(i)
}
wg.Wait()
}
+56
View File
@@ -0,0 +1,56 @@
package eventbus
import "sync"
type RingBuffer[T any] struct {
mu sync.Mutex
buf []T
head int
tail int
count int
cap int
}
func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
return &RingBuffer[T]{
buf: make([]T, capacity),
cap: capacity,
}
}
func (r *RingBuffer[T]) Push(item T) {
r.mu.Lock()
defer r.mu.Unlock()
r.buf[r.head] = item
r.head = (r.head + 1) % r.cap
if r.count == r.cap {
r.tail = (r.tail + 1) % r.cap
} else {
r.count++
}
}
func (r *RingBuffer[T]) Pop() (T, bool) {
r.mu.Lock()
defer r.mu.Unlock()
var zero T
if r.count == 0 {
return zero, false
}
item := r.buf[r.tail]
r.buf[r.tail] = zero
r.tail = (r.tail + 1) % r.cap
r.count--
return item, true
}
func (r *RingBuffer[T]) Len() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.count
}
+109
View File
@@ -0,0 +1,109 @@
package eventbus
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRingBuffer_PushPop(t *testing.T) {
ring := NewRingBuffer[int](5)
ring.Push(1)
ring.Push(2)
ring.Push(3)
v, ok := ring.Pop()
require.True(t, ok)
assert.Equal(t, 1, v)
v, ok = ring.Pop()
require.True(t, ok)
assert.Equal(t, 2, v)
v, ok = ring.Pop()
require.True(t, ok)
assert.Equal(t, 3, v)
}
func TestRingBuffer_Empty(t *testing.T) {
ring := NewRingBuffer[int](5)
v, ok := ring.Pop()
assert.False(t, ok)
assert.Equal(t, 0, v)
}
func TestRingBuffer_OverwriteOldest(t *testing.T) {
ring := NewRingBuffer[int](4)
ring.Push(1)
ring.Push(2)
ring.Push(3)
ring.Push(4)
ring.Push(5)
ring.Push(6)
var values []int
for {
v, ok := ring.Pop()
if !ok {
break
}
values = append(values, v)
}
assert.Equal(t, []int{3, 4, 5, 6}, values)
}
func TestRingBuffer_Len(t *testing.T) {
ring := NewRingBuffer[int](5)
assert.Equal(t, 0, ring.Len())
ring.Push(1)
assert.Equal(t, 1, ring.Len())
ring.Push(2)
ring.Push(3)
assert.Equal(t, 3, ring.Len())
ring.Pop()
assert.Equal(t, 2, ring.Len())
ring.Push(4)
ring.Push(5)
ring.Push(6)
ring.Push(7)
assert.Equal(t, 5, ring.Len())
ring.Push(8)
assert.Equal(t, 5, ring.Len())
}
func TestRingBuffer_Concurrent(t *testing.T) {
ring := NewRingBuffer[int](100)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
ring.Push(id*100 + j)
}
}(i)
}
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 500; i++ {
ring.Pop()
}
}()
wg.Wait()
}
+32
View File
@@ -0,0 +1,32 @@
package hello
import (
"context"
"google.golang.org/grpc"
pb "homelab.lan/music-agregator/gen/music_agregator/hello/v1"
)
type HelloServer struct {
pb.UnimplementedHelloServiceServer
}
func NewHelloServer() *HelloServer {
return &HelloServer{}
}
func (server *HelloServer) Ping(ctx context.Context, req *pb.PingRequest) (*pb.PongResponse, error) {
return &pb.PongResponse{}, nil
}
func (server *HelloServer) Echo(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
response := &pb.EchoResponse{
Response: req.GetMsg(),
}
return response, nil
}
func (s *HelloServer) Register(server *grpc.Server) {
pb.RegisterHelloServiceServer(server, s)
}
+79
View File
@@ -0,0 +1,79 @@
package indexer
import (
"sync"
"time"
"github.com/rs/zerolog/log"
)
type CacheEntry struct {
Key string
URL string
Result SearchResult
CreatedAt time.Time
TTL time.Duration
RefreshInterval time.Duration
}
func (e *CacheEntry) IsExpired() bool {
return time.Now().After(e.CreatedAt.Add(e.TTL))
}
type IndexerCache struct {
entries map[string]*CacheEntry
mu sync.RWMutex
}
func NewIndexerCache() *IndexerCache {
return &IndexerCache{
entries: make(map[string]*CacheEntry),
}
}
func (c *IndexerCache) Get(key string) (*CacheEntry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok {
log.Trace().Str("key", key).Msg("cache miss")
return nil, false
}
if entry.IsExpired() {
log.Trace().Str("key", key).Msg("cache expired")
return nil, false
}
log.Trace().Str("key", key).Int("items", len(entry.Result.Items)).Msg("cache hit")
return entry, true
}
func (c *IndexerCache) Add(entry CacheEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[entry.Key] = &entry
log.Debug().Str("key", entry.Key).Int("items", len(entry.Result.Items)).Dur("ttl", entry.TTL).Dur("refresh", entry.RefreshInterval).Msg("cache entry added")
}
func (c *IndexerCache) Update(key string, result SearchResult) {
c.mu.Lock()
defer c.mu.Unlock()
if entry, ok := c.entries[key]; ok {
entry.Result = result
log.Debug().Str("key", key).Int("items", len(result.Items)).Msg("cache entry updated")
} else {
log.Warn().Str("key", key).Msg("cache update for missing entry")
}
}
func (c *IndexerCache) Remove(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
log.Debug().Str("key", key).Msg("cache entry removed")
}
+71
View File
@@ -0,0 +1,71 @@
package indexer
import (
"context"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
)
type CacheRefreshArgs struct {
Key string `json:"key"`
URL string `json:"url"`
TTLExpires time.Time `json:"ttl_expires"`
RefreshInterval time.Duration `json:"refresh_interval"`
}
func (CacheRefreshArgs) Kind() string { return "indexer_cache_refresh" }
type CacheRefreshWorker struct {
river.WorkerDefaults[CacheRefreshArgs]
Cache *IndexerCache
Indexer Indexer
RiverClient *river.Client[pgx.Tx]
}
func (w *CacheRefreshWorker) Work(ctx context.Context, job *river.Job[CacheRefreshArgs]) error {
args := job.Args
if w.Cache == nil || w.Indexer == nil {
log.Trace().Str("key", args.Key).Msg("cache disabled, discarding refresh job")
return nil
}
log.Trace().Str("key", args.Key).Int64("job_id", job.ID).Time("ttl_expires", args.TTLExpires).Msg("cache refresh worker started")
if time.Now().After(args.TTLExpires) {
w.Cache.Remove(args.Key)
log.Debug().Str("key", args.Key).Msg("cache entry TTL expired, removed")
return nil
}
log.Trace().Str("key", args.Key).Str("url", args.URL).Msg("fetching fresh data from indexer")
start := time.Now()
result, err := w.Indexer.FetchURL(args.URL)
if err != nil {
retryAt := time.Now().Add(5 * time.Minute)
log.Error().Err(err).Str("key", args.Key).Time("retry_at", retryAt).Msg("cache refresh failed, scheduling retry")
w.RiverClient.Insert(ctx, args, &river.InsertOpts{
ScheduledAt: retryAt,
})
return nil
}
log.Trace().Str("key", args.Key).Int("items", len(result.Items)).Dur("duration", time.Since(start)).Msg("fresh data fetched")
w.Cache.Update(args.Key, result)
nextRefresh := time.Now().Add(args.RefreshInterval)
_, err = w.RiverClient.Insert(ctx, args, &river.InsertOpts{
ScheduledAt: nextRefresh,
})
if err != nil {
log.Error().Err(err).Str("key", args.Key).Msg("failed to schedule next cache refresh")
} else {
log.Trace().Str("key", args.Key).Time("next_refresh", nextRefresh).Msg("next refresh scheduled")
}
log.Debug().Str("key", args.Key).Int("items", len(result.Items)).Msg("cache refreshed")
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package indexer
import (
"context"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
"homelab.lan/music-agregator/internal/config"
)
type CachedIndexer struct {
inner Indexer
cache *IndexerCache
riverClient *river.Client[pgx.Tx]
cfg config.CacheConfig
}
func NewCachedIndexer(inner Indexer, cache *IndexerCache, riverClient *river.Client[pgx.Tx], cfg config.CacheConfig) *CachedIndexer {
return &CachedIndexer{
inner: inner,
cache: cache,
riverClient: riverClient,
cfg: cfg,
}
}
func (c *CachedIndexer) Search(query string, limit int32, tracker string) (SearchResult, error) {
key := query + "|" + tracker
log.Trace().Str("key", key).Str("query", query).Str("tracker", tracker).Msg("cached indexer search")
if entry, ok := c.cache.Get(key); ok {
log.Debug().Str("key", key).Int("items", len(entry.Result.Items)).Msg("returning cached result")
return entry.Result, nil
}
log.Trace().Str("key", key).Msg("cache miss, fetching from indexer")
result, err := c.inner.Search(query, limit, tracker)
if err != nil {
log.Error().Err(err).Str("key", key).Msg("cached indexer fetch failed")
return SearchResult{}, err
}
url := c.inner.BuildSearchURL(query, limit, tracker)
log.Trace().Str("key", key).Str("url", url).Int("items", len(result.Items)).Msg("caching result")
c.cache.Add(CacheEntry{
Key: key,
URL: url,
Result: result,
CreatedAt: time.Now(),
TTL: c.cfg.TTL,
RefreshInterval: c.cfg.RefreshInterval,
})
scheduleAt := time.Now().Add(c.cfg.RefreshInterval)
_, err = c.riverClient.Insert(context.Background(), CacheRefreshArgs{
Key: key,
URL: url,
TTLExpires: time.Now().Add(c.cfg.TTL),
RefreshInterval: c.cfg.RefreshInterval,
}, &river.InsertOpts{
ScheduledAt: scheduleAt,
})
if err != nil {
log.Error().Err(err).Str("key", key).Msg("failed to schedule cache refresh job")
} else {
log.Debug().Str("key", key).Time("scheduled_at", scheduleAt).Msg("cache refresh job scheduled")
}
log.Debug().Str("key", key).Dur("ttl", c.cfg.TTL).Dur("refresh", c.cfg.RefreshInterval).Int("items", len(result.Items)).Msg("cached indexer search complete")
return result, nil
}
func (c *CachedIndexer) FetchURL(url string) (SearchResult, error) {
log.Trace().Str("url", url).Msg("cached indexer fetch URL passthrough")
return c.inner.FetchURL(url)
}
func (c *CachedIndexer) BuildSearchURL(query string, limit int32, tracker string) string {
return c.inner.BuildSearchURL(query, limit, tracker)
}
func (c *CachedIndexer) Capabilities(indexerName string) (IndexerCapabilities, error) {
log.Trace().Str("indexer", indexerName).Msg("cached indexer capabilities passthrough")
return c.inner.Capabilities(indexerName)
}
+5
View File
@@ -0,0 +1,5 @@
package indexer
type Filter interface {
IsKnownCategory(categories []string) bool
}
+8
View File
@@ -0,0 +1,8 @@
package indexer
type Indexer interface {
Search(query string, limit int32, indexer string) (SearchResult, error)
FetchURL(url string) (SearchResult, error)
BuildSearchURL(query string, limit int32, tracker string) string
Capabilities(indexerName string) (IndexerCapabilities, error)
}
+103
View File
@@ -0,0 +1,103 @@
package indexer
import (
"encoding/xml"
"strings"
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
)
type IndexerCapabilities struct {
XMLName xml.Name `xml:"caps"`
Server Server `xml:"server"`
Limits Limits `xml:"limits"`
Searching Searching `xml:"searching"`
Categories []Category `xml:"categories>category"`
}
type Server struct {
Title string `xml:"title,attr"`
}
type Limits struct {
Default int `xml:"default,attr"`
Max int `xml:"max,attr"`
}
type Searching struct {
Search SearchCapability `xml:"search"`
TvSearch SearchCapability `xml:"tv-search"`
MovieSearch SearchCapability `xml:"movie-search"`
MusicSearch SearchCapability `xml:"music-search"`
AudioSearch SearchCapability `xml:"audio-search"`
BookSearch SearchCapability `xml:"book-search"`
}
type SearchCapability struct {
Available string `xml:"available,attr"`
SupportedParams string `xml:"supportedParams,attr"`
SearchEngine string `xml:"searchEngine,attr"`
}
type Category struct {
ID int `xml:"id,attr"`
Name string `xml:"name,attr"`
Subcats []Subcat `xml:"subcat"`
}
type Subcat struct {
ID int `xml:"id,attr"`
Name string `xml:"name,attr"`
}
func (c *IndexerCapabilities) ToProto() *pb.CapabilitiesResponse {
return &pb.CapabilitiesResponse{
Server: &pb.Server{
Title: c.Server.Title,
},
Limits: &pb.Limits{
Default: int32(c.Limits.Default),
Max: int32(c.Limits.Max),
},
Searching: &pb.Searching{
Search: c.Searching.Search.toProto(),
TvSearch: c.Searching.TvSearch.toProto(),
MovieSearch: c.Searching.MovieSearch.toProto(),
MusicSearch: c.Searching.MusicSearch.toProto(),
AudioSearch: c.Searching.AudioSearch.toProto(),
BookSearch: c.Searching.BookSearch.toProto(),
},
Categories: c.categoriesToProto(),
}
}
func (s *SearchCapability) toProto() *pb.SearchCapability {
var params []string
if s.SupportedParams != "" {
params = strings.Split(s.SupportedParams, ",")
}
return &pb.SearchCapability{
Available: s.Available == "yes",
SupportedParams: params,
SearchEngine: s.SearchEngine,
}
}
func (c *IndexerCapabilities) categoriesToProto() []*pb.Category {
categories := make([]*pb.Category, len(c.Categories))
for i, cat := range c.Categories {
subcats := make([]*pb.Subcat, len(cat.Subcats))
for j, sub := range cat.Subcats {
subcats[j] = &pb.Subcat{
Id: int32(sub.ID),
Name: sub.Name,
}
}
categories[i] = &pb.Category{
Id: int32(cat.ID),
Name: cat.Name,
Subcats: subcats,
}
}
return categories
}
+139
View File
@@ -0,0 +1,139 @@
package indexer
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/rs/zerolog/log"
"homelab.lan/music-agregator/internal/config"
)
type JacketIndexer struct {
cfg config.Config
client *http.Client
}
func NewIndexer(cfg config.Config) Indexer {
return &JacketIndexer{
cfg: cfg,
client: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (indexer *JacketIndexer) BuildSearchURL(query string, limit int32, tracker string) string {
searchTracker := "all"
if len(tracker) != 0 {
searchTracker = tracker
}
uri := fmt.Sprintf("%v/api/v2.0/indexers/%v/results/torznab?apikey=%v&cat=3010,3040&q=%v&t=search",
indexer.cfg.Indexer.Url, searchTracker, indexer.cfg.Indexer.ApiKey, url.QueryEscape(query))
if limit > 0 {
uri += fmt.Sprintf("&limit=%d", limit)
}
return uri
}
func (indexer *JacketIndexer) Search(query string, limit int32, tracker string) (SearchResult, error) {
uri := indexer.BuildSearchURL(query, limit, tracker)
return indexer.FetchURL(uri)
}
type JackettError struct {
Code string `xml:"code,attr"`
Description string `xml:"description,attr"`
}
func (e *JackettError) Error() string {
return fmt.Sprintf("jackett error %s: %s", e.Code, e.Description)
}
func (indexer *JacketIndexer) FetchURL(uri string) (SearchResult, error) {
log.Trace().Str("uri", uri).Msg("jackett request")
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
log.Error().Err(err).Msg("error creating request")
return SearchResult{}, err
}
start := time.Now()
resp, err := indexer.client.Do(req)
if err != nil {
log.Error().Err(err).Msg("error making search request")
return SearchResult{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("error reading search response body")
return SearchResult{}, err
}
log.Trace().
Int("status", resp.StatusCode).
Int("body_bytes", len(body)).
Dur("duration", time.Since(start)).
Msg("jackett response")
if resp.StatusCode != http.StatusOK {
var jackettErr JackettError
if xmlErr := xml.Unmarshal(body, &jackettErr); xmlErr == nil && jackettErr.Code != "" {
log.Error().Str("code", jackettErr.Code).Str("description", jackettErr.Description).Msg("jackett returned error")
return SearchResult{}, &jackettErr
}
return SearchResult{}, fmt.Errorf("jackett returned HTTP %d", resp.StatusCode)
}
var searchResult SearchResult
if err := xml.Unmarshal(body, &searchResult); err != nil {
log.Error().Err(err).Msg("error parsing search XML")
return SearchResult{}, err
}
log.Trace().Int("items", len(searchResult.Items)).Msg("jackett XML parsed")
return searchResult, nil
}
func (indexer *JacketIndexer) Capabilities(indexerName string) (IndexerCapabilities, error) {
url := indexer.cfg.Indexer.Url
uri := fmt.Sprintf("%v/api/v2.0/indexers/%v/results/torznab/api?apikey=%v&t=caps", url, indexerName, indexer.cfg.Indexer.ApiKey)
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
log.Error().Err(err).Msg("Error creating request")
return IndexerCapabilities{}, err
}
resp, err := indexer.client.Do(req)
if err != nil {
log.Error().Err(err).Msg("Error making capabilities request")
return IndexerCapabilities{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Error reading response body")
return IndexerCapabilities{}, err
}
var capabilities IndexerCapabilities
if err := xml.Unmarshal(body, &capabilities); err != nil {
log.Error().Err(err).Msg("Error parsing capabilities XML")
return IndexerCapabilities{}, err
}
log.Debug().Str("server", capabilities.Server.Title).Msg("Parsed capabilities")
return capabilities, nil
}
+140
View File
@@ -0,0 +1,140 @@
package indexer
import (
"encoding/xml"
"strconv"
"github.com/rs/zerolog/log"
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
"homelab.lan/music-agregator/internal/release"
"homelab.lan/music-agregator/internal/tracker"
)
type SearchResult struct {
XMLName xml.Name `xml:"rss"`
Items []Item `xml:"channel>item"`
}
type JackettIndexer struct {
ID string `xml:"id,attr"`
}
type Item struct {
Title string `xml:"title"`
Link string `xml:"link"`
Guid string `xml:"guid"`
PubDate string `xml:"pubDate"`
Size int64 `xml:"size"`
Description string `xml:"description"`
Categories []string `xml:"category"`
Enclosure Enclosure `xml:"enclosure"`
TorznabAttrs []TorznabAttr `xml:"attr"`
JackettIndexer JackettIndexer `xml:"jackettindexer"`
}
type Enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
}
type TorznabAttr struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
type SearchItemResult struct {
Title string
DownloadLink string
TorrentPageUrl string
PubDate string
Size int64
Description string
Categories []string
Tracker string
Seeders int
Peers int
Attrs map[string]string
Release *release.Release
}
func (si *SearchItemResult) ToProto() *pb.SearchItem {
var pbAttrs []*pb.TorznabAttr
for k, v := range si.Attrs {
pbAttrs = append(pbAttrs, &pb.TorznabAttr{Name: k, Value: v})
}
return &pb.SearchItem{
Title: si.Title,
DownloadLink: si.DownloadLink,
TorrentPageUrl: si.TorrentPageUrl,
PubDate: si.PubDate,
Size: si.Size,
Description: si.Description,
Categories: si.Categories,
TorznabAttrs: pbAttrs,
Release: si.Release.ToProto(),
}
}
type SearchResponse struct {
Items []*SearchItemResult
}
func (sr *SearchResponse) ToProto() *pb.SearchResponse {
pbItems := make([]*pb.SearchItem, len(sr.Items))
for i, item := range sr.Items {
pbItems[i] = item.ToProto()
}
return &pb.SearchResponse{Result: pbItems}
}
var genericParser = tracker.NewGenericParser()
func (sr *SearchResult) ToSearchResponse() *SearchResponse {
var items []*SearchItemResult
for _, item := range sr.Items {
rel := genericParser.Parse(item.Title)
log.Trace().
Str("tracker", item.JackettIndexer.ID).
Str("title", item.Title).
Str("artist", rel.Artist).
Str("album", rel.Album).
Int("year", rel.Year).
Bool("parsed", rel.ParsedSuccessfully).
Msg("parsed item")
attrs := make(map[string]string, len(item.TorznabAttrs))
for _, attr := range item.TorznabAttrs {
attrs[attr.Name] = attr.Value
}
seeders, _ := strconv.Atoi(attrs["seeders"])
peers, _ := strconv.Atoi(attrs["peers"])
items = append(items, &SearchItemResult{
Title: item.Title,
DownloadLink: item.Link,
TorrentPageUrl: item.Guid,
PubDate: item.PubDate,
Size: item.Size,
Description: item.Description,
Categories: item.Categories,
Tracker: item.JackettIndexer.ID,
Seeders: seeders,
Peers: peers,
Attrs: attrs,
Release: rel,
})
}
log.Trace().
Int("total", len(sr.Items)).
Int("items", len(items)).
Msg("conversion complete")
return &SearchResponse{Items: items}
}
+67
View File
@@ -0,0 +1,67 @@
package indexer
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
"homelab.lan/music-agregator/internal/config"
)
type IndexerServer struct {
service *IndexerService
pb.UnimplementedIndexerServiceServer
}
func NewIndexerServer(cfg config.Config, riverClient *river.Client[pgx.Tx], cacheWorker *CacheRefreshWorker) (*IndexerServer, error) {
service, err := NewIndexerService(cfg, riverClient, cacheWorker)
if err != nil {
log.Err(err).Msg("failed to initialize IndexerService")
return nil, err
}
return &IndexerServer{service: service}, nil
}
func (server *IndexerServer) Search(ctx context.Context, req *pb.SearchRequest) (*pb.SearchResponse, error) {
log.Debug().
Str("query", req.GetQuery()).
Int32("limit", req.GetLimit()).
Str("tracker", req.GetTracker()).
Msg("search started")
log.Trace().Str("query", req.GetQuery()).Msg("fetching results from indexer")
resp, err := server.service.Search(req.GetQuery(), req.GetLimit(), req.GetTracker())
if err != nil {
log.Error().Err(err).Str("query", req.GetQuery()).Msg("search failed")
return nil, err
}
log.Debug().
Str("query", req.GetQuery()).
Int("results", len(resp.Items)).
Msg("search completed")
return resp.ToProto(), nil
}
func (server *IndexerServer) Capabilities(ctx context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
log.Debug().Str("indexer", req.GetIndexer()).Msg("capabilities requested")
resp, err := server.service.Capabilities(req)
if err != nil {
log.Error().Err(err).Str("indexer", req.GetIndexer()).Msg("capabilities failed")
return nil, err
}
return resp, nil
}
func (s *IndexerServer) Register(server *grpc.Server) {
pb.RegisterIndexerServiceServer(server, s)
}
+66
View File
@@ -0,0 +1,66 @@
package indexer
import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
"homelab.lan/music-agregator/internal/config"
)
type Searcher interface {
Search(query string, limit int32, indexer string) (*SearchResponse, error)
}
type IndexerService struct {
indexer Indexer
}
func NewIndexerService(cfg config.Config, riverClient *river.Client[pgx.Tx], cacheWorker *CacheRefreshWorker) (*IndexerService, error) {
var idx Indexer
switch cfg.Indexer.Type {
case config.IndexerTypeJackett:
idx = NewIndexer(cfg)
default:
return nil, fmt.Errorf("unable to create the indexer for type: %v", cfg.Indexer.Type)
}
if cfg.Indexer.Cache.Enabled && riverClient != nil {
cache := NewIndexerCache()
idx = NewCachedIndexer(idx, cache, riverClient, cfg.Indexer.Cache)
if cacheWorker != nil {
cacheWorker.Cache = cache
cacheWorker.Indexer = idx
}
log.Info().Dur("ttl", cfg.Indexer.Cache.TTL).Dur("refresh", cfg.Indexer.Cache.RefreshInterval).Msg("indexer cache enabled")
}
return &IndexerService{indexer: idx}, nil
}
func (service *IndexerService) Search(query string, limit int32, indexer string) (*SearchResponse, error) {
searchResult, err := service.indexer.Search(query, limit, indexer)
if err != nil {
log.Error().Err(err).Msg("failed to search in indexer")
return nil, err
}
log.Trace().Int("raw_items", len(searchResult.Items)).Msg("indexer returned results")
return searchResult.ToSearchResponse(), nil
}
func (service *IndexerService) Capabilities(req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
capabilities, err := service.indexer.Capabilities(req.GetIndexer())
if err != nil {
log.Error().Err(err).Msg("Failed to get capabilities from indexer")
return nil, err
}
return capabilities.ToProto(), nil
}

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