Compare commits

..

44 Commits

Author SHA1 Message Date
Alexander 99ede6a67a Tora vpn work 2026-08-18 09:57:25 +02:00
Alexander 47c256a6b4 Update tora: userspace WireGuard VPN mode
torad --wireguard-config brings up a WireGuard tunnel entirely in user
space and confines all torrent traffic to it. No sudo, no wg-quick, no
kernel module, no host setup.

Adds passt, wireguard-go and iproute2 to the dev environment; torad
execs all three at runtime.
2026-08-17 23:09:12 +02:00
Alexander 270ae72f46 Update tora: librqbit v9 + --bind-device (VPN mode PR1)
Picks up the librqbit 8.1.1 -> 9.0.0-rc.0 upgrade and the new
--bind-device flag, which is the enforcement primitive for VPN mode:
SO_BINDTODEVICE on every librqbit socket so torrent traffic cannot fall
back to the host route when the tunnel drops.

No behaviour change here — torad is not launched with --bind-device yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:17:59 +02:00
Alexander c14349848e Update submodules 2026-07-25 12:44:41 +02:00
Alexander 2a4098769a Update metadata on download finish 2026-07-25 12:07:27 +02:00
Alexander 36225bbf40 Update tora 2026-07-19 17:40:10 +02:00
Alexander d66a4035e0 Download torrent when monitor album 2026-07-19 17:36:38 +02:00
Alexander 72b07e1186 Update submodule 2026-07-17 17:57:16 +02:00
Alexander 9b43485f11 Start work on Monitor Album 2026-07-17 17:55:57 +02:00
Alexander 44776ca09c Add metadata provider 2026-07-12 16:52:33 +02:00
Alexander 18f2fa9475 Add search for torrents 2026-07-12 10:36:14 +02:00
Alexander 38bee269db Tora connection + health + config 2026-07-06 15:21:34 +02:00
Alexander af7d69980f Start from scratch 2026-07-01 21:16:40 +02:00
Alexander d52ce9508e Some more 2026-07-01 20:07:49 +02:00
Alexander b448e076bc Leftovers 2026-07-01 20:07:31 +02:00
Alexander b5b175a8e9 Publish download events from PollDownloadWorker to event stream
PollDownloadWorker now publishes status/result/error events to both the
EventBus (for live UI updates via SubscribeEvents) and AlbumEventRepository
(for DB persistence and replay on reconnect).

- Add AlbumID, Quality, WorkflowRunID to PollDownloadArgs
- Add EventBus and AlbumEvents fields to PollDownloadWorker
- Publish status events with progress % on each poll tick
- Publish result event on download completion
- Publish error events on torrent failure
- Thread workflowRunID through saveTorrentAndDownload for event correlation
- RecoverOrphanedDownloads populates AlbumID/Quality from download record

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 14:57:25 +02:00
Alexander b5849d6ad1 Wire shared EventBus from main into PollDownloadWorker and server
Create EventBus in serveGrpc() and pass to both setupRiver() and
NewMusicAgregatorServer() so PollDownloadWorker and the gRPC server
share the same event bus instance.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 14:57:02 +02:00
Alexander 908c37f73f Add NixOS module for full agregators stack with VM test
Module manages PostgreSQL (both databases + schema init), qBittorrent
(VPN-confined via VPN-Confinement), Jackett, metadata-agregator,
musicfs, and the main orchestrator. All services are independently
enableable with auto-wired inter-service configuration.

Includes NixOS VM test validating PostgreSQL setup, schema
initialization, service startup, and directory creation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 12:39:04 +02:00
Alexander 10a5fd5707 Package with nix, some fixes 2026-05-20 09:46:43 +02:00
Alexander 76e426c5d3 feat: integrate musicfs for post-download metadata enrichment
- Add internal/musicfs package: gRPC client, TriggerRescan (stream consumer), EnrichFiles (BatchUpdateMetadata), DeriveSubdir helper
- Add MusicFS config section (enabled, endpoint, origin_id, origin_root, timeout)
- Wire MusicFSClient into PollDownloadWorker: on download completion, trigger scoped rescan then push enriched metadata
- Fix album auto-persist in monitor workflow: persist artist+album from metadata-agregator before saving torrent/download
- Add filterByActiveSeeders: skip torrents where magnet resolution found 0 real active seeders
- Add musicfs proto to buf.gen.yaml inputs
- Enricher only sends non-empty fields to avoid overwriting existing file tag data with empty values
2026-05-17 23:32:44 +02:00
Alexander eac8196dcc Add beetfs submodule with reverse-engineered documentation
Fork of github.com:LichHunter/beetfs - a FUSE filesystem for metadata
overlay on audio files. Includes comprehensive documentation covering:
- Architecture and component analysis
- Data flow diagrams for read/write operations
- Performance analysis (latency, memory footprint)
- 27 identified drawbacks and limitations
- Python 3 modernization guide
2026-05-12 11:52:55 +02:00
Alexander 451fd97c95 Return NotFound error when no torrents or quality matches found instead of silent empty response
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 20:46:40 +02:00
Alexander 0fd51a1e03 Retry indexer search with stripped parenthetical suffixes when initial query returns no results
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 20:38:39 +02:00
Alexander 9aff4b0a29 Return all downloads per album grouped by quality, filter out failed/cancelled from listings
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 20:15:33 +02:00
Alexander ff6bc444d0 Prefer torrents with real seeders: resolve connected seeder count from swarm during magnet resolution
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 20:02:02 +02:00
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
218 changed files with 28985 additions and 13384 deletions
+8 -1
View File
@@ -1 +1,8 @@
use flake
#!/usr/bin/env bash
export GIT_CONFIG_GLOBAL=/dev/null
eval "$(devenv direnvrc)"
# You can pass flags to the devenv command
# For example: use devenv --impure --option services.postgres.enable:bool true
use devenv
+29 -2
View File
@@ -4,6 +4,9 @@ config.yaml
/server
/vendor
pkg/metadatapb/
.claude/*
.codegraph/*
.omo/*
# Ignore all
*
@@ -28,5 +31,29 @@ bin/
# Ignore config.yaml
config.yaml
# Ignore protobuf generated files
gen/
# Protobuf generated Go code — committed for reproducible builds
# Regenerate with: buf generate
# gen/
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# Runtime artifacts (downloads, FUSE mount, etc.)
dev/
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
# Added by cargo
/target
data/*
+9
View File
@@ -0,0 +1,9 @@
[submodule "musicfs"]
path = musicfs
url = ssh://git@gitea.susano-homelab.duckdns.org:2222/fujin/MusicFS.git
[submodule "tora"]
path = tora
url = ssh://git@gitea.susano-homelab.duckdns.org:2222/fujin/tora.git
[submodule "metadata-agregator"]
path = metadata-agregator
url = ssh://git@gitea.susano-homelab.duckdns.org:2222/fujin/metadata-agregator.git
+1 -1
View File
@@ -1 +1 @@
/nix/store/mchzk3cbvp456fd3nbajm120nrry3pls-pre-commit-config.json
/nix/store/rh5id2b2rs7200wyvlfl5pgh8ba4i5nq-pre-commit-config.json
Generated
+4240
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "music-agregator"
version = "0.1.0"
edition = "2024"
[features]
default = ["proto_full", "serde"]
serde = ["dep:serde"]
# @@protoc_deletion_point(features)
# This section is automatically generated by protoc-gen-prost-crate.
# Changes in this area may be lost on regeneration.
proto_full = ["torrent"]
"torrent" = []
# @@protoc_insertion_point(features)
[dependencies]
bytes = "1.11.1"
prost = "0.14.1"
pbjson = "0.8.0"
pbjson-types = "0.8.0"
serde = { version = "1.0.228", optional = true }
tonic = { version = "0.14.2", features = ["gzip"] }
tonic-prost = "0.14.2"
tokio = { version = "1.52.3", features = [ "macros", "rt-multi-thread" ] }
tonic-reflection = "0.14.6"
tonic-health = "0.14.6"
toml = "1.1.2"
reqwest = { version = "0.13.4", default-features = false, features = ["query", "rustls"] }
quick-xml = { version = "0.41.0", features = ["serialize"] }
regex = "1"
sha1 = "0.10"
tracing = "0.1"
tracing-subscriber = "0.3"
librqbit = { version = "8", default-features = false, features = ["rust-tls"] }
tempfile = "3"
anyhow = "1"
sqlx = { version = "0.9", default-features = false, features = ["postgres", "runtime-tokio", "tls-rustls", "json", "uuid"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
dateparser = "0.2"
chrono = "0.4"
itertools = "0.15.0"
[workspace]
-22
View File
@@ -1,22 +0,0 @@
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"
}
'''
}
-20
View File
@@ -1,20 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,21 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,25 +0,0 @@
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
@@ -1,8 +0,0 @@
meta {
name: Indexer
seq: 7
}
auth {
mode: inherit
}
-20
View File
@@ -1,20 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,8 +0,0 @@
meta {
name: Jackett
seq: 5
}
auth {
mode: inherit
}
-22
View File
@@ -1,22 +0,0 @@
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"
}
'''
}
@@ -1,22 +0,0 @@
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
@@ -1,22 +0,0 @@
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"
}
'''
}
-22
View File
@@ -1,22 +0,0 @@
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
@@ -1,8 +0,0 @@
meta {
name: Metadata Agregator
seq: 6
}
auth {
mode: inherit
}
-26
View File
@@ -1,26 +0,0 @@
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
}
'''
}
-27
View File
@@ -1,27 +0,0 @@
{
"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
+28
View File
@@ -0,0 +1,28 @@
# Backlog
<!-- SECTION: ENTRIES -->
<!-- SECTION: Music-Agregator -->
- [ ] [P2] Every restart of devenv - recreates Jackett container *(priority: P2)*
- [ ] [P2] Implement reconnection on service restart *(priority: P2)*
- [x] [P2] Update devenv services to properly show ready state similarly to postgres *(priority: P2, progress: 100)*
- [x] [P1] Implement Monitor Album *(progress: 100)*
- [ ] [P2] Implement download source, for example autodownload songs from youtube *(priority: P2)*
<!-- SECTION: HISTORY -->
<!-- SECTION: Music-Agregator -->
| Timestamp | Item ID | Action | Details |
|-----------|---------|--------|---------|
| 2026-08-17T20:06:49.708Z | n-rzxtwn | item_created | Implement download source, for example autodownload songs from youtube |
| 2026-07-25T10:47:17.547Z | n-2upxei | item_created | Implement Monitor Album |
| 2026-07-25T10:43:53.501Z | i-ms07jr3l | status_changed | open → done |
| 2026-07-22T20:48:17.097Z | n-zlin24 | item_created | Update devenv services to properly show ready state similarly to postgres |
| 2026-07-20T10:27:43.739Z | n-wynn47 | item_created | Implement reconnection on service restart |
| 2026-07-19T12:57:24.051Z | n-ddy1wp | item_created | Every restart of devenv - recreates Jackett container |
<!-- SECTION: INTEGRITY -->
<!-- saved: 2026-08-17T20:06:50Z | checksum: sha256:554921aff327d32a7356897361eaf36820d973eb6ad0af68219d2aaa2e6c9557 | entries: 5 | history: 6 -->
+28 -7
View File
@@ -1,11 +1,32 @@
version: v2
inputs:
- directory: proto
- directory: ../metadata-agregator/proto
- directory: ./metadata-agregator/proto
- directory: ./musicfs/proto
- directory: ./tora/proto
plugins:
- local: protoc-gen-go
out: gen
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: gen
opt: paths=source_relative
- local: protoc-gen-prost
#path: ../../target/debug/protoc-gen-prost
out: src/generated
opt:
# - bytes=.
- compile_well_known_types
- extern_path=.google.protobuf=::pbjson_types
- file_descriptor_set
- enable_type_names
- local: protoc-gen-prost-serde
#path: ../../target/debug/protoc-gen-prost-serde
out: src/generated
- local: protoc-gen-tonic
#path: ../../target/debug/protoc-gen-tonic
out: src/generated
opt:
- compile_well_known_types
- extern_path=.google.protobuf=::pbjson_types
- local: protoc-gen-prost-crate
#path: ../../target/debug/protoc-gen-prost-crate
out: .
strategy: all
opt:
- include_file=src/generated/mod.rs
- gen_crate
-227
View File
@@ -1,227 +0,0 @@
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/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) *riverSetup {
cacheWorker := &indexer.CacheRefreshWorker{}
pollWorker := &workers.PollDownloadWorker{
Downloads: database.NewDownloadRepository(db.Pool),
DownloadFiles: database.NewDownloadFileRepository(db.Pool),
TorrentClient: torrent.MustNewTorrentClient(cfg),
}
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()
rs := setupRiver(ctx, config, db)
musiscAgregatorSeerver, err := internal.NewMusicAgregatorServer(config, rs.client, 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, err := torrent.NewTorrentServer(config)
if err != nil {
log.Fatal().Err(err).Msg("failed to create TorrentServer")
}
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
}
-31
View File
@@ -1,31 +0,0 @@
app:
port: 3000
database:
url: "postgresql://music:music@localhost:5433/music_aggregator"
metadata:
endpoint: "http://localhost:50051"
indexer:
name: "Jackett"
indexer_type: jackett # jackett, prowlarr, or torznab
url: "http://localhost:9117"
api_key: "your-jackett-api-key"
# Torrent client - choose one of: qbittorrent, stub, none
torrent:
client_type: qbittorrent
url: "http://localhost:8080"
username: "admin"
password: "changeme"
# Alternative: stub client for testing
# torrent:
# client_type: stub
# log_path: "/tmp/torrent-stub.log"
# save_path: "/tmp/downloads"
# Alternative: no torrent client
# torrent:
# client_type: none
-22
View File
@@ -1,22 +0,0 @@
app:
port: 3000
database:
url: "postgresql://music:music@localhost:5433/music_aggregator"
metadata:
endpoint: "http://localhost:50051"
indexers:
- name: "Jackett"
indexer_type: jackett
url: "http://localhost:9117"
api_key: "apmtthy50kiu47wth8i1we22k5sn8ut6"
torrent:
client_type: stub
save_path: "/tmp/music-downloads"
log_path: "/tmp/torrent-stub.log"
storage:
base_path: "/tmp/music-library-test"
+13
View File
@@ -0,0 +1,13 @@
[service]
address = "[::1]:50051"
[indexer]
address = "http://localhost:9117"
api = "feuxigkphd3z1q9yruxc3grp2fcs2reh"
[torrent]
address = "unix:///run/user/1000/devenv-ae542bd/torad.sock"
[metadata]
address = "http://localhost:50053"
[musicfs]
address = "http://127.0.0.1:50052"
[database]
url = "postgres://fujin@localhost:5432/music_agregator"
-63
View File
@@ -1,63 +0,0 @@
services:
jackett:
image: lscr.io/linuxserver/jackett:latest
container_name: music-aggregator-jackett
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Warsaw
- AUTO_UPDATE=true
volumes:
- jackett_config:/config
- jackett_downloads:/downloads
ports:
- "9117:9117"
gluetun:
image: qmcgaw/gluetun:latest
container_name: music-aggregator-vpn
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=custom
- VPN_TYPE=wireguard
- TZ=Europe/Warsaw
volumes:
- ./wg.conf:/gluetun/wireguard/wg0.conf:ro
ports:
- "8080:8080"
- "6881:6881"
- "6881:6881/udp"
healthcheck:
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
container_name: music-aggregator-qbittorrent
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Warsaw
- WEBUI_PORT=8080
volumes:
- qbittorrent_config:/config
- downloads:/downloads
volumes:
jackett_config:
jackett_downloads:
qbittorrent_config:
downloads:
+37
View File
@@ -0,0 +1,37 @@
{
pkgs,
lib,
config,
...
}:
{
# Rootless container runtime. No daemon needed, unlike Docker.
packages = [ pkgs.podman ];
# Jackett indexer, running rootless via podman so it joins `devenv up`
# alongside postgres/torad/musicfs. Foreground (no `-d`) so process-compose
# supervises it and streams logs; `--rm` cleans up on `devenv up` shutdown.
# UI: http://localhost:9117
#
# Note: podman (unlike docker) does NOT auto-create bind-mount source dirs,
# so we `mkdir -p` them first or podman fails with "statfs: no such file".
processes.jackett.exec =
let
jackettConfig = "${config.env.DEVENV_RUNTIME}/jackett-config";
jackettDownloads = "${config.env.DEVENV_RUNTIME}/jackett-downloads";
in
''
mkdir -p "${jackettConfig}" "${jackettDownloads}"
${pkgs.podman}/bin/podman run --rm \
--name devenv-jackett \
-p 9117:9117 \
-e PUID=1000 \
-e PGID=100 \
-e TZ=Europe/Warsaw \
-e AUTO_UPDATE=true \
-v "${jackettConfig}":/config \
-v "${jackettDownloads}":/downloads \
lscr.io/linuxserver/jackett:latest
'';
}
+84
View File
@@ -0,0 +1,84 @@
-- Cached parsed-torrent metadata. Mirrors the Rust `ParsedTorrent` struct
-- field-for-field. A UUID `id` is the PK so rows can be inserted during the
-- fast path (title-only, no info_hash yet) and updated when the background
-- resolver finishes.
--
-- A row exists in one of three states:
-- 'partial' - title-derived fields only, awaiting background resolution
-- 'resolving'- a background resolver task is currently fetching torrent bytes
-- 'resolved' - full torrent-derived data filled in
CREATE TABLE parsed_torrents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
info_hash TEXT NOT NULL DEFAULT '',
raw_title TEXT NOT NULL DEFAULT '',
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
year INTEGER NOT NULL DEFAULT 0,
release_type TEXT NOT NULL DEFAULT 'unknown',
genres JSONB NOT NULL DEFAULT '[]'::jsonb,
label TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'unknown',
rip_type TEXT NOT NULL DEFAULT '',
format TEXT NOT NULL DEFAULT 'unknown',
bitrate TEXT NOT NULL DEFAULT '',
bit_depth INTEGER NOT NULL DEFAULT 0,
sample_rate INTEGER NOT NULL DEFAULT 0,
track_names JSONB NOT NULL DEFAULT '[]'::jsonb,
track_count INTEGER NOT NULL DEFAULT 0,
release_count INTEGER NOT NULL DEFAULT 0,
audio_file_count INTEGER NOT NULL DEFAULT 0,
total_audio_size BIGINT 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,
parsed_successfully BOOLEAN NOT NULL DEFAULT false,
parse_errors JSONB NOT NULL DEFAULT '[]'::jsonb,
-- cache-control columns (not part of ParsedTorrent)
state TEXT NOT NULL DEFAULT 'partial',
indexer TEXT NOT NULL DEFAULT '',
magnet TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- enum-backed fields (release_type, source, format) are stored as their
-- `as_str()` repr in plain TEXT rather than native ENUMs, so adding a variant
-- to the Rust enum needs no migration.
-- lets the background resolver find rows still awaiting torrent resolution
CREATE INDEX parsed_torrents_state_idx
ON parsed_torrents (state)
WHERE state IN ('partial', 'resolving');
-- dedup resolved torrents by info_hash (only meaningful once non-empty)
CREATE UNIQUE INDEX parsed_torrents_info_hash_uidx
ON parsed_torrents (info_hash)
WHERE info_hash != '';
-- Albums that music-agregator has pointed tora at. One row per
-- (album_id, quality) pair: a re-monitor of the same album at the same
-- quality is rejected by the UNIQUE constraint until the previous row is
-- removed. The `torrent_id` column links back to tora's torrent; it's what
-- the startup notification subscriber uses to look up which album a
-- STATE_CHANGED -> FINISHED event belongs to.
CREATE TABLE monitored_albums (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
album_id TEXT NOT NULL,
torrent_id UUID NOT NULL,
quality TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
UNIQUE (album_id, quality)
);
CREATE UNIQUE INDEX monitored_albums_torrent_id_uidx
ON monitored_albums (torrent_id);
+9
View File
@@ -0,0 +1,9 @@
# Secrets
.env*
# Dependencies
node_modules
# OS files
.DS_Store
Thumbs.db
+11
View File
@@ -0,0 +1,11 @@
info:
name: Health
type: grpc
seq: 1
grpc:
url: localhost:50051
method: /health.Health/Check
methodType: unary
message: "{}"
auth: inherit
+24
View File
@@ -0,0 +1,24 @@
info:
name: Search
type: http
seq: 2
http:
method: GET
url: http://localhost:9117/api/v2.0/indexers/all/results/torznab/api?apikey=feuxigkphd3z1q9yruxc3grp2fcs2reh&t=search&q=ДДТ
params:
- name: apikey
value: feuxigkphd3z1q9yruxc3grp2fcs2reh
type: query
- name: t
value: search
type: query
- name: q
value: ДДТ
type: query
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+7
View File
@@ -0,0 +1,7 @@
info:
name: Jackett
type: folder
seq: 3
request:
auth: inherit
@@ -0,0 +1,22 @@
info:
name: Search Album
type: grpc
seq: 2
grpc:
url: localhost:50051
method: /metadata.v1.MetadataService/SearchAlbums
methodType: unary
message: |-
{
"query": "arbor",
"artist": "sub",
"limit": 982,
"offset": 27,
"provider": 0,
"album_types": [
"natus",
"natus"
]
}
auth: inherit
@@ -0,0 +1,14 @@
info:
name: SearchArtists
type: grpc
seq: 1
grpc:
url: localhost:50051
method: /metadata.v1.MetadataService/SearchArtists
methodType: unary
message: |-
{
"query": "ДДТ"
}
auth: inherit
+7
View File
@@ -0,0 +1,7 @@
info:
name: Metadata
type: folder
seq: 4
request:
auth: inherit
+16
View File
@@ -0,0 +1,16 @@
info:
name: Montitor Album
type: grpc
seq: 5
grpc:
url: localhost:50051
method: /music.v1.MusicService/MonitorAlbum
methodType: unary
message: |-
{
"album": "Творчество в пустоте",
"artist": "ДДТ",
"quality": 0
}
auth: inherit
@@ -0,0 +1,11 @@
info:
name: List Files
type: grpc
seq: 1
grpc:
url: localhost:50051
method: /musicfs.ClientControl/ListFiles
methodType: unary
message: "{}"
auth: inherit
+7
View File
@@ -0,0 +1,7 @@
info:
name: Musicfs
type: folder
seq: 7
request:
auth: inherit
+14
View File
@@ -0,0 +1,14 @@
info:
name: Add
type: grpc
seq: 3
grpc:
url: localhost:50051
method: /torrent.Torrents/Add
methodType: unary
message: |-
{
"magnet": "magnet:?xt=urn:btih:C1A42E5046D5AC2346D5EE47A44F8630FD1C33FB&tr=http%3A%2F%2Fbt2.t-ru.org%2Fann%3Fmagnet&dn=(%D0%A0%D0%BE%D0%BA)%20%D0%94%D0%94%D0%A2%20(DDT)%20-%20%D0%A2%D0%B2%D0%BE%D1%80%D1%87%D0%B5%D1%81%D1%82%D0%B2%D0%BE%20%D0%B2%20%D0%BF%D1%83%D1%81%D1%82%D0%BE%D1%82%D0%B5%20-%202021%2C%20MP3%2C%20320%20kbps"
}
auth: inherit
+11
View File
@@ -0,0 +1,11 @@
info:
name: List
type: grpc
seq: 1
grpc:
url: localhost:50051
method: /torrent.Torrents/List
methodType: unary
message: "{}"
auth: inherit
+15
View File
@@ -0,0 +1,15 @@
info:
name: Remove
type: grpc
seq: 2
grpc:
url: localhost:50051
method: /torrent.Torrents/Remove
methodType: unary
message: |-
{
"id": "37d6c87c-db55-4f9d-ad32-f582c395b061",
"delete_files": true
}
auth: inherit
+14
View File
@@ -0,0 +1,14 @@
info:
name: Status
type: grpc
seq: 4
grpc:
url: localhost:50051
method: /torrent.Torrents/Status
methodType: unary
message: |-
{
"id": "ea0e2fec-08ec-4bcd-909c-4dedccfdcb6e"
}
auth: inherit
+7
View File
@@ -0,0 +1,7 @@
info:
name: Tora
type: folder
seq: 6
request:
auth: inherit
+14
View File
@@ -0,0 +1,14 @@
info:
name: Search
type: grpc
seq: 2
grpc:
url: localhost:50051
method: /torrent_manager.TorrentManager/Search
methodType: unary
message: |-
{
"name": "ДДТ Творчество в пустоте"
}
auth: inherit
+7
View File
@@ -0,0 +1,7 @@
info:
name: Torrent
type: folder
seq: 2
request:
auth: inherit
+25
View File
@@ -0,0 +1,25 @@
opencollection: 1.0.0
info:
name: Music Agregator
config:
proxy:
inherit: true
config:
protocol: http
hostname: ""
port: ""
auth:
username: ""
password: ""
bypassProxy: ""
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
presets:
request:
type: grpc
url: localhost:50051
+504
View File
@@ -0,0 +1,504 @@
{
"nodes": {
"bun2nix": {
"inputs": {
"flake-parts": "flake-parts_2",
"nixpkgs": [
"pinix",
"nixpkgs"
],
"systems": [
"pinix",
"systems"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1778446047,
"narHash": "sha256-oQvcadh2BCkrog+SGrG6YffKJrveYpjj3TdQJWaKhaM=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "f2bc12af1a6369648aac41041ceeaa0b866599c6",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "2.1.0",
"repo": "bun2nix",
"type": "github"
}
},
"cachix": {
"inputs": {
"devenv": [
"crate2nix"
],
"flake-compat": [
"crate2nix"
],
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"crate2nix": {
"inputs": {
"cachix": "cachix",
"devshell": "devshell",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nix-test-runner": "nix-test-runner",
"nixpkgs": [
"nixpkgs"
],
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1782759041,
"narHash": "sha256-2577vxyoBa8+ZRiXr3CuPtOuEtPRYsFPSZuEc/KI/80=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "5e1ecfd2d15b34ec90c2e51fdffbe8116595a767",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "crate2nix",
"type": "github"
}
},
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1787015266,
"narHash": "sha256-B4eXU1+QiZc3fFgH0pTl185xNUYNzW+1KIe9Jmtavkk=",
"owner": "cachix",
"repo": "devenv",
"rev": "c18de5253016e6eb97689724327f08e9dd2d4768",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": [
"pinix",
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1777988971,
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"crate2nix",
"cachix",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"crate2nix",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"git-hooks_2": {
"inputs": {
"flake-compat": "flake-compat_2",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784288435,
"narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"crate2nix",
"cachix",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_2": {
"inputs": {
"nixpkgs": [
"crate2nix",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"jail-nix": {
"locked": {
"lastModified": 1776230864,
"narHash": "sha256-YsEjjdOsGEzTeD+iT7ONh071BqWAOQWpzYVei3okAXE=",
"owner": "~alexdavid",
"repo": "jail.nix",
"rev": "404e7da9da5ab9aa643666682b2ba1312fa5fbe8",
"type": "sourcehut"
},
"original": {
"owner": "~alexdavid",
"repo": "jail.nix",
"type": "sourcehut"
}
},
"nix-test-runner": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1786858016,
"narHash": "sha256-Vczr2zxD0orq6N2QQe5uqOGF7TRDcQJRRuAcsdHr4kg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "54ba4bcec4043e72a4006d825e0d7aff5562008f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1787002832,
"narHash": "sha256-DGkHh88/7YLVGZ7HzSVN4YmUmR+wGKudIp6H9UsUNT0=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "ee3d58d53cfcddfc0ae6fc7f04f4fe2a0c7cf0ed",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1773646010,
"narHash": "sha256-iYrs97hS7p5u4lQzuNWzuALGIOdkPXvjz7bviiBjUu8=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5b2c2d84341b2afb5647081c1386a80d7a8d8605",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"pinix": {
"inputs": {
"bun2nix": "bun2nix",
"jail-nix": "jail-nix",
"nixpkgs": "nixpkgs_3",
"systems": "systems"
},
"locked": {
"lastModified": 1787028847,
"narHash": "sha256-R/0D4Cs8gzhtRyT4fqiK41AfM2DTMGNeTcdcyaLyhRQ=",
"owner": "lukasl-dev",
"repo": "pi.nix",
"rev": "273a55220a90e4c50fbfcd88e6b432796fba0e58",
"type": "github"
},
"original": {
"owner": "lukasl-dev",
"repo": "pi.nix",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"crate2nix",
"flake-compat"
],
"gitignore": "gitignore_2",
"nixpkgs": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"crate2nix": "crate2nix",
"devenv": "devenv",
"git-hooks": "git-hooks_2",
"nixpkgs": "nixpkgs_2",
"pinix": "pinix",
"treefmt-nix": "treefmt-nix_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"pinix",
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1775636079,
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
},
"treefmt-nix_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786901030,
"narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+84
View File
@@ -0,0 +1,84 @@
{
pkgs,
lib,
config,
inputs,
...
}:
{
imports = [ ./containers/jackett.nix ];
languages.rust.enable = true;
git-hooks.hooks = {
clippy = {
enable = true;
settings.allFeatures = true;
};
treefmt.enable = true;
};
treefmt = {
enable = true;
config.programs = {
nixfmt.enable = true;
rustfmt.enable = true;
};
};
packages = with pkgs; [
git
just
protobuf
buf
grpcurl
# Protobuf generators
protoc-gen-prost-crate
protoc-gen-prost-serde
protoc-gen-tonic
protoc-gen-prost
# torad VPN mode (--wireguard-config): pasta creates the unprivileged
# namespace, wireguard-go is the userspace datapath, `ip` configures the
# tunnel. See tora/devenv.nix for why wireguard-tools is not needed.
passt
wireguard-go
iproute2
opencode
];
services.postgres = {
enable = true;
initialDatabases = [
{
name = "music_agregator";
schema = ./db/schema.sql;
}
];
};
# torad downloads into dev/downloads/<torrent_name>/ and moves finished
# torrents to dev/downloads/completed/<torrent_name>/ on completion.
# musicfs scans only the completed/ subtree so it never sees partially
# written files.
# $TORAD_ARGS is unquoted on purpose: it word-splits, so any torad flag can be
# passed through without editing this file (see `just up` in the justfile).
processes.torad.exec = lib.mkForce ''
mkdir -p "$PWD/dev/downloads"
${config.outputs.torad}/bin/torad \
--socket "$TORAD_SOCKET" \
--pid-file "$TORAD_PID_FILE" \
--download-dir "$PWD/dev/downloads" \
$TORAD_ARGS
'';
processes.musicfs.exec = lib.mkForce ''
mkdir -p "$PWD/dev/downloads/completed" /tmp/dev-music
${config.outputs.musicfs}/bin/musicfs \
--source "$PWD/dev/downloads/completed" \
--mountpoint /tmp/dev-music \
--database "postgresql://fujin@localhost/musicfs?host=$PGHOST"
'';
}
+18
View File
@@ -0,0 +1,18 @@
inputs:
git-hooks:
url: github:cachix/git-hooks.nix
inputs:
nixpkgs:
follows: nixpkgs
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
treefmt-nix:
url: github:numtide/treefmt-nix
inputs:
nixpkgs:
follows: nixpkgs
imports:
- ./musicfs
- ./tora
- ./metadata-agregator
@@ -0,0 +1,86 @@
@startuml Event Bus Architecture
skinparam componentAlign center
title Event Bus: In-Process Pub/Sub Architecture
package "Publishers" {
[Workflow Goroutine 1\n(album A, LOSSLESS)] as WF1
[Workflow Goroutine 2\n(album B, LOSSY)] as WF2
}
database "PostgreSQL" as DB {
[workflow_runs] as WR
[album_events] as AE
}
package "Event Bus (in-memory)" {
[Topic: albumA:LOSSLESS] as T1
[Topic: albumB:LOSSY] as T2
[Global Subscribers] as GS
}
package "Subscribers" {
[MonitorAlbumStream\nClient A (album A)] as S1
[MonitorAlbumStream\nClient B (album A)] as S2
[SubscribeEvents\nClient C (global)] as S3
}
WF1 --> DB : 1. Write event\n(synchronous)
WF1 --> T1 : 2. Publish\n(async notification)
WF2 --> DB : 1. Write event
WF2 --> T2 : 2. Publish
T1 --> S1 : Ring buffer\n(per subscriber)
T1 --> S2 : Ring buffer
T1 --> GS
T2 --> GS
GS --> S3 : Ring buffer
note right of DB
**Source of truth.**
Events survive restarts.
Replay via seq numbers.
end note
note right of T1
**Ephemeral notification.**
Ring buffer per subscriber.
Slow subscribers: overwrite oldest.
No backpressure on publishers.
end note
note bottom of S1
Client disconnect removes
subscriber from topic.
Workflow continues.
end note
== Subscription Lifecycle ==
note as N1
**Subscribe flow:**
1. Client calls MonitorAlbumStream or SubscribeEvents
2. Server subscribes to EventBus (per-topic or global)
3. Server queries DB for historical events (replay)
4. Server bridges: EventBus → gRPC stream
5. On disconnect: cleanup func unsubscribes
**Topic cleanup:**
When last subscriber leaves AND workflow completed:
topic removed from EventBus map.
end note
== Recovery on Restart ==
note as N2
**Server restart recovery:**
1. Query workflow_runs WHERE status = 'running'
2. For each stale run:
- If active download exists → mark completed
- Otherwise → mark failed ("server restarted")
3. RecoverOrphanedDownloads reschedules poll jobs
4. New workflows start fresh (no goroutine resurrection)
end note
@enduml
@@ -0,0 +1,109 @@
@startuml MonitorAlbumStream - Already Owned Scenarios
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Already Owned Handling
actor Client
participant "monitorWorkflow" as Workflow
participant "EventBus" as Bus
database "PostgreSQL" as DB
participant "IndexerService" as Indexer
note over Client, Indexer #lightblue
All events are persisted to album_events table (DB first)
then published to EventBus for live subscribers.
In automatic mode, workflow runs as background goroutine.
end note
== Scenario A: Automatic Mode - Early Return ==
Client -> Workflow: StartMonitorRequest(mode=AUTOMATIC)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Fetching album metadata..."
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Got: Artist - Title"\nData: StreamAlbumInfo
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: StatusUpdate(COMPLETE)
note right #lightgreen: "Already owned"
Workflow ->> Client: MonitorAlbumResponse
note right: album: monitored\ndownload: existing info\nrelease: nil (no search)
note over Client, Workflow #lightblue
**Automatic Mode**: Skips search entirely.
Returns immediately with existing download info.
end note
== Scenario B: Manual Mode - User Confirms Continue ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false
Client -> Workflow: UserDecision(confirm=true)
note right #lightgreen: User chooses\nto continue
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
note right: Proceeds with normal flow...
Workflow -> Indexer: Search()
note right: ... continues to completion
== Scenario C: Manual Mode - User Skips ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"
Client -> Workflow: UserDecision(confirm=false)
note right #lightyellow: User chooses\nto skip
Workflow ->> Client: StatusUpdate(COMPLETE)
note right: "Skipped - already owned"
Workflow ->> Client: MonitorAlbumResponse
note right: album: monitored\ndownload: existing info\nrelease: nil
== Scenario D: Manual Mode - Timeout ==
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow ->> Client: PromptForDecision
note over Client, Workflow #lightyellow
Client does not respond within timeout (max: 300s)
end note
Workflow -> Workflow: Use default decision
note right: default: false\n(skip download)
Workflow ->> Client: StatusUpdate(COMPLETE)
note right: "Skipped - already owned"
Workflow ->> Client: MonitorAlbumResponse
@enduml
@@ -0,0 +1,99 @@
@startuml MonitorAlbumStream - Automatic Mode Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Automatic Mode (Fire-and-Forget)
actor Client
participant "gRPC Server" as Server
participant "WorkflowRegistry" as Registry
participant "EventBus" as Bus
participant "monitorWorkflow\n(background goroutine)" as Workflow
database "PostgreSQL" as DB
participant "MusicAgregatorService" as Service
participant "IndexerService" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
== 1. Initialize Stream ==
Client -> Server: MonitorAlbumStream()
Server -> Client: stream established
Client -> Server: StartMonitorRequest
note right: album_id, quality\nmode = AUTOMATIC
== 2. Start or Subscribe to Workflow ==
Server -> Registry: GetOrCreate(albumID, quality)
alt New workflow
Registry --> Server: (entry, created=true)
Server -> Bus: Subscribe(topic)
note right #lightblue: Subscribe BEFORE\nstarting goroutine\n(no missed events)
Server -> Workflow: go workflow.run(entry.Ctx)
note right #lightyellow: Background goroutine.\nDecoupled from stream context.\nClient can disconnect freely.
else Existing workflow
Registry --> Server: (entry, created=false)
Server -> Bus: Subscribe(topic)
Server -> DB: Replay album_events\n(since last seq)
DB --> Server: historical events
Server ->> Client: replayed events
end
== 3. Event Bridge (concurrent) ==
note over Client, Workflow #lightblue
**Left side**: Event bus → gRPC stream bridge
**Right side**: Workflow executing in background
Both run concurrently. Client disconnect only stops the bridge.
end note
|||
Workflow -> DB: album_events.Create(FETCHING_METADATA)
Workflow -> Bus: Publish(FETCHING_METADATA)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(FETCHING_METADATA)
Workflow -> Service: getAlbumWithPersist()
Workflow -> DB: workflow_runs.Create(albumID, quality)
Workflow -> DB: album_events.Create(CHECKING_OWNED)
Workflow -> Bus: Publish(CHECKING_OWNED)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(CHECKING_OWNED)
Workflow -> Indexer: Search()
Workflow -> DB: album_events.Create(PARSING_RESULTS)
Workflow -> Bus: Publish(PARSING_RESULTS)
Bus ->> Server: event notification
Server ->> Client: StatusUpdate(PARSING_RESULTS)
Workflow -> Workflow: filterByQuality + selectBest
Workflow -> DB: saveTorrentAndDownload()
note right: DB save BEFORE qBit add\n(prevents orphan torrents)
Workflow -> QBit: AddMagnet()
Workflow -> DB: album_events.Create(COMPLETE)
Workflow -> Bus: Publish(COMPLETE)
Bus ->> Server: event notification
Server ->> Client: MonitorAlbumResponse
note right #lightgreen: Final result
Workflow -> DB: workflow_runs.SetCompleted()
Workflow -> Registry: Remove(albumID, quality)
== 4. Client Disconnect (Fire-and-Forget) ==
note over Client, Workflow #lightyellow
Client can disconnect at ANY point during the workflow.
The workflow goroutine continues independently.
Another client can subscribe to the same workflow later.
end note
@enduml
@@ -0,0 +1,157 @@
@startuml MonitorAlbumStream - Manual Mode Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbumStream: Manual Mode (Interactive Prompts)
actor Client
participant "gRPC Server" as Server
participant "monitorWorkflow" as Workflow
participant "MusicAgregatorService" as Service
database "PostgreSQL" as DB
participant "IndexerService" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
== 1. Initialize Stream ==
Client -> Server: MonitorAlbumStream()
Server -> Client: stream established
Client -> Server: StartMonitorRequest
note right: album_id, quality\nmode = MANUAL
Server -> Workflow: newMonitorWorkflow()
== 2. Fetch Metadata ==
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: "Fetching album metadata..."
Workflow -> Service: getAlbumWithPersist()
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
note right #lightblue: Data: StreamAlbumInfo\n{artist, title, release_date,\nalready_owned, owned_quality}
== 3. Check Ownership (Interactive) ==
Workflow -> DB: downloads.HasAlbumInQuality()
DB --> Workflow: true (already owned!)
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
note right #lightyellow: "Already owned in FLAC quality"
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false\ntimeout: max 300s
Client -> Server: UserDecision
note right: confirm: true\n(user chooses to continue)
Workflow -> Workflow: Continue with search
== 4. Search & Parse ==
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
Workflow -> Indexer: Search()
Indexer --> Workflow: 3 results
loop parse results
Workflow -> Magnet: Resolve()
end
Workflow ->> Client: StatusUpdate(PARSING_RESULTS)
note right #lightblue: Data: TorrentList\n[{id, title, seeders, format}, ...]
== 5. Select Torrents (Interactive) ==
Workflow ->> Client: PromptForDecision
note right #orange: type: SELECT_MANY\nmessage: "Select torrents to consider"\noptions: [{id, label, description}, ...]\ndefault: all selected\nmin: 1, max: N
Client -> Server: UserDecision
note right: selected_ids: ["torrent-0", "torrent-2"]\n(user deselects torrent-1)
Workflow -> Workflow: Filter to selected torrents
== 6. Filter by Quality ==
Workflow ->> Client: StatusUpdate(FILTERING_QUALITY)
Workflow -> Workflow: filterByQuality()
note right: 2 releases remain\nafter quality filter
== 7. Select Release (Interactive) ==
Workflow ->> Client: PromptForDecision
note right #orange: type: SELECT_ONE\nmessage: "Select release"\noptions: [{id, label, description}, ...]\ndefault: highest seeders
Client -> Server: UserDecision
note right: selected_id: "release-1"\n(user picks specific release)
Workflow ->> Client: StatusUpdate(SELECTING_RELEASE)
note right #lightblue: Data: ReleaseInfo\n{hash, format, seeders, tracker}
== 8. Confirm Add (Interactive) ==
Workflow ->> Client: StatusUpdate(ADDING_TORRENT)
note right #lightyellow: "Adding torrent: Title..."
Workflow ->> Client: PromptForDecision
note right #orange: type: CONFIRM\nmessage: "Add torrent 'Title' to client?"\nconfirm_label: "Add"\ncancel_label: "Skip"\ndefault: true
Client -> Server: UserDecision
note right: confirm: true
== 9. Add & Save ==
Workflow -> QBit: AddMagnet()
QBit --> Workflow: OK
Workflow ->> Client: StatusUpdate(SAVING)
Workflow -> DB: Create torrent & download
== 10. Complete ==
Workflow ->> Client: StatusUpdate(COMPLETE)
note right #lightblue: "Download started"
Workflow ->> Client: MonitorAlbumResponse
note right #lightgreen: Final result
== Cancel Cleanup (Disconnect or Cancel Message) ==
note over Client, QBit #salmon
**Manual Mode: Disconnect = Cancel**
When client disconnects or sends CancelRequest:
1. Workflow context is cancelled (stops further processing)
2. If torrent was added to qBit: **DeleteTorrent(hash)** removes it + files
3. If download record exists: marked as **cancelled** in DB
4. workflow_run marked as **cancelled** in DB
5. All events persisted to album_events for audit trail
Cleanup uses a fresh context (not the cancelled one).
end note
== Decision Points Summary ==
note over Client, QBit #lightyellow
**Manual Mode Decision Points:**
1. **CHECKING_OWNED** (CONFIRM)
- Triggered when: Album already owned in requested quality
- Default: false (skip)
- Timeout action: Use default
2. **PARSING_RESULTS** (SELECT_MANY)
- Triggered when: Multiple torrents found (>1)
- Default: All selected
- Timeout action: Use defaults
3. **SELECTING_RELEASE** (SELECT_ONE)
- Triggered when: Multiple releases after quality filter (>1)
- Default: Highest seeders
- Timeout action: Use default
4. **ADDING_TORRENT** (CONFIRM)
- Triggered: Always in manual mode
- Default: true (add)
- Timeout action: Use default
end note
@enduml
@@ -0,0 +1,144 @@
@startuml MonitorAlbumStream Protocol
skinparam sequenceMessageAlign center
title MonitorAlbumStream & SubscribeEvents: Message Protocol
participant "Client" as C
participant "Server" as S
== MonitorAlbumStream (Bidirectional) ==
C -> S: gRPC MonitorAlbumStream()
note right: Opens bidirectional stream
== Request Messages (Client -> Server) ==
C -> S: MonitorAlbumStreamRequest
note right #lightblue
**oneof message:**
- **start**: StartMonitorRequest
- album_id (required)
- quality: LOSSLESS | LOSSY | UNSPECIFIED
- mode: AUTOMATIC (0) | MANUAL (1)
- indexer_options: {tracker}
- **decision**: UserDecision
- prompt_id (must match pending prompt)
- confirm | selected_id | selected_ids
- **cancel**: CancelRequest
- Gracefully terminates workflow
end note
== Response Messages (Server -> Client) ==
S -> C: MonitorAlbumStreamResponse
note left #lightgreen
**oneof message:**
- **status**: StatusUpdate
- step: MonitorStep enum
- message: human-readable text
- data: StreamAlbumInfo | TorrentList | ReleaseInfo
- **prompt**: PromptForDecision
- prompt_id: unique identifier
- type: CONFIRM | SELECT_ONE | SELECT_MANY
- message: prompt text
- timeout_seconds: response deadline
- options: confirm | select_one | select_many config
- **result**: MonitorAlbumResponse
- Final response (stream ends after this)
- **error**: ErrorUpdate
- failed_step: where error occurred
- message: error description
- recoverable: bool
end note
== SubscribeEvents (Server-Side Stream) ==
C -> S: SubscribeEvents(SubscribeEventsRequest)
note right #lightyellow
**SubscribeEventsRequest:**
- since_seq: int64 (0 = live only, >0 = replay from seq)
**Response stream: AlbumEvent**
- seq: int64 (monotonic sequence number)
- workflow_run_id, album_id, quality
- event_type: status | error | result
- step: MonitorStep name
- message: human-readable text
- data_json: bytes (optional structured data)
- timestamp_ms: int64
**Global firehose**: receives events from ALL running workflows.
Client-side filtering by album_id if needed.
end note
== Event Flow Architecture ==
note over C, S #lightblue
**Event Path (DB first, bus second):**
1. Workflow step executes
2. Event written to **album_events** table (synchronous, durable)
3. Event published to **EventBus** (async, ephemeral notification)
4. Bus fans out to subscribers:
- MonitorAlbumStream clients (per-topic)
- SubscribeEvents clients (global)
5. Subscribers convert event to proto and stream.Send()
**DB is source of truth. Bus is notification layer.**
Events are never lost, even if no subscribers are connected.
end note
== Monitor Steps ==
note over C, S #lightyellow
**MonitorStep Enum:**
1. FETCHING_METADATA - Getting album info from metadata service
2. CHECKING_OWNED - Checking if already downloaded
3. SEARCHING_INDEXER - Querying Jackett/indexers
4. PARSING_RESULTS - Resolving magnets, parsing torrents
5. FILTERING_QUALITY - Applying quality filter
6. SELECTING_RELEASE - Choosing best/user-selected release
7. ADDING_TORRENT - Adding to qBittorrent
8. SAVING - Persisting to database
9. COMPLETE - Workflow finished
end note
== Mode Comparison ==
note over C, S #lightyellow
**AUTOMATIC mode:**
- Workflow runs as background goroutine
- Client can disconnect, workflow continues (fire-and-forget)
- Duplicate request for same album+quality subscribes to existing
- Events delivered via EventBus bridge
**MANUAL mode:**
- Workflow runs inline in stream handler
- Interactive prompts at 4 decision points
- Disconnect = cancel = full cleanup (qBit delete + DB cancel)
- Events delivered directly via stream + persisted to DB
end note
== Persistence ==
note over C, S #lightgreen
**workflow_runs table:**
- Tracks workflow lifecycle: running → completed | failed | cancelled
- Unique constraint: one running workflow per album+quality
- Used for deduplication, recovery, and audit
**album_events table:**
- Audit log of all workflow events
- seq BIGSERIAL for ordering and replay
- Supports subscribe-before-query replay pattern
end note
== Timeout Behavior ==
note over C, S #pink
When prompt times out (max: 300s):
- Server uses **default decision** value
- Workflow continues automatically
- No error is raised
end note
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

@@ -0,0 +1,193 @@
@startuml Release Parsing Decision Tree
skinparam ActivityBackgroundColor #f8f8f8
skinparam ActivityBorderColor #333333
skinparam DiamondBackgroundColor #fffde7
skinparam NoteBackgroundColor #e3f2fd
title Release Parsing Decision Tree
start
partition "1. Resolve Torrent Data" {
if (DownloadLink starts\nwith "magnet:?") then (yes)
:MagnetResolver.Resolve(magnetURI);
note right
DHT lookup via anacrolix/torrent
30s timeout, 15s early exit
if peers but none active
end note
if (Resolve succeeded?) then (yes)
:torrentData = resolved bytes;
else (no)
:fallback to **title-only parse**;
note right
parser.Parse(item.Title)
No torrent data available
No info_hash computed
end note
goto TitleOnlyParse
endif
else (HTTP link)
:downloadTorrentData(url);
note right
HTTP GET with 30s timeout
Expects .torrent file bytes
end note
if (Download succeeded?) then (yes)
:torrentData = downloaded bytes;
else (no)
:fallback to **title-only parse**;
goto TitleOnlyParse
endif
endif
}
partition "2. ParseTorrent (torrentData + metadata album)" {
partition "2a. Fill from Metadata Album" {
:Artist = album.Artists[0].Name;
:Album = album.Title;
:Year = album.ReleaseDate[:4];
:Type = album.AlbumType
(album/ep/single/compilation/...);
:Genres = album.Genres[].Name;
:Label = album.Label.Name;
:TrackCount = album.TotalTracks;
:ReleaseCount = album.TotalDiscs;
}
partition "2b. Fill from Torrent Data" {
:metainfo.Load(torrentData);
note right
Bencode decode via
anacrolix/torrent/metainfo
end note
if (Parse failed?) then (yes)
:Append to ParseErrors;
:Skip torrent analysis;
else (no)
:info = mi.UnmarshalInfo();
if (Unmarshal failed?) then (yes)
:Append to ParseErrors;
:Skip torrent analysis;
else (no)
:RawTitle = info.Name;
:InfoHash = SHA1(info dict);
if (info.Files is empty?\n(single-file torrent)) then (yes)
:ext = filepath.Ext(info.Name);
if (ext is audio?\n(.flac/.mp3/.aac/...)) then (yes)
:Format = audioExtensions[ext];
:AudioFileCount = 1;
:TotalAudioSize = info.Length;
else (no)
:Format = unknown;
endif
else (multi-file torrent)
:Iterate all files in torrent;
repeat
:file = next torrent file;
:ext = filepath.Ext(file.Path);
if (ext is audio?) then (yes)
:formatCounts[ext]++;
:formatSizes[ext] += file.Length;
:TrackNames += cleanTrackName(file);
note right
Strip leading "01. " or "1 - "
from filename
end note
elseif (ext is .jpg/.jpeg/.png?) then (yes)
:HasCoverArt = true;
elseif (ext is .cue?) then (yes)
:HasCueSheet = true;
elseif (ext is .log?) then (yes)
:HasRipLog = true;
endif
repeat while (more files?)
:Format = dominant format\n(most audio files);
:AudioFileCount = count of dominant;
:TotalAudioSize = sum of dominant;
endif
if (HasRipLog?) then (yes)
:Source = CD;
note right
.log file = EAC/XLD rip log
implies CD source
end note
endif
if (TrackCount == 0?) then (yes)
:TrackCount = AudioFileCount;
endif
endif
endif
}
partition "2c. Fill from Title (torrent name)" #f0f0f0 {
label TitleParsing
:title = info.Name (or item.Title for fallback);
if (title matches\n"(\\d{2,3})\\s*kbps"?) then (yes)
:Bitrate = matched value + " kbps";
endif
:Try hi-res patterns (in order):;
note right
1. "24Bit-96kHz" / "24 Bit / 48 kHz"
2. "FLAC 24-96" / "Flac 24-44"
3. "24Bit" (bit depth only)
First match wins.
end note
if (Hi-res pattern matched?) then (yes)
if (BitDepth still 0?) then (yes)
:BitDepth = matched group 1;
endif
if (SampleRate still 0\nand group 2 exists?) then (yes)
:SampleRate = matched × 1000;
endif
endif
if (title matches\n"\\[(CD|WEB|Vinyl|...)\\]"\nand Source still unknown?) then (yes)
:Source = matched value;
note right
CD, WEB, Vinyl/LP,
Cassette/MC, DVD,
Blu-Ray
end note
endif
if (title matches\nrip type pattern?) then (yes)
:RipType = matched value;
note right
vinyl rip, SACD-R,
HDCD, DSD, tape rip
end note
endif
}
:ParsedSuccessfully = (Artist != "" && Album != "");
if (not ParsedSuccessfully?) then (yes)
:ParseErrors += "missing artist or album";
endif
:Return Release;
stop
}
partition "3. Title-Only Parse (fallback)" #fff3e0 {
label TitleOnlyParse
:r = Release{RawTitle: item.Title};
note right
No torrent data available.
No InfoHash. No file analysis.
No TrackNames. No cover/cue/log.
Format stays **unknown**.
end note
goto TitleParsing
}
@enduml
-362
View File
@@ -1,362 +0,0 @@
@startuml Lidarr-Style Music Aggregator ERD
skinparam linetype ortho
skinparam ranksep 60
skinparam nodesep 40
skinparam entity {
BackgroundColor White
BorderColor #333333
}
skinparam package {
BackgroundColor #FAFAFA
BorderColor #DDDDDD
}
title Music Aggregator - Lidarr-Style Database Structure
' ══════════════════════════════════════════════════════════════
' CORE MUSIC ENTITIES
' ══════════════════════════════════════════════════════════════
package "Core Music Entities" #E3F2FD {
entity "artist_metadata" {
* 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
ratings : JSONB
members : JSONB
--
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
tags : INT[]
}
entity "albums" {
* id : UUID <<PK>>
--
artist_metadata_id : UUID <<FK>>
--
foreign_album_id : TEXT <<UNIQUE>>
title : TEXT
clean_title : TEXT
disambiguation : TEXT
overview : TEXT
album_type : TEXT
secondary_types : JSONB
release_date : DATE
images : JSONB
links : JSONB
genres : JSONB
ratings : JSONB
--
monitored : BOOLEAN
any_release_ok : BOOLEAN
last_search_time : TIMESTAMPTZ
added_at : TIMESTAMPTZ
}
entity "album_releases" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>>
--
foreign_release_id : TEXT <<UNIQUE>>
title : TEXT
disambiguation : TEXT
status : TEXT
duration_ms : INT
release_date : DATE
country : TEXT[]
label : TEXT[]
media : JSONB
track_count : INT
--
monitored : BOOLEAN
}
entity "tracks" {
* id : UUID <<PK>>
--
album_release_id : UUID <<FK>>
artist_metadata_id : UUID <<FK>>
track_file_id : UUID <<FK NULL>>
--
foreign_track_id : TEXT <<UNIQUE>>
foreign_recording_id : TEXT
title : TEXT
track_number : INT
disc_number : INT
duration_ms : INT
explicit : BOOLEAN
ratings : JSONB
}
entity "track_files" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>>
--
path : TEXT
relative_path : TEXT
size : BIGINT
quality : JSONB
media_info : JSONB
audio_tags : JSONB
--
scene_name : TEXT
release_group : TEXT
--
date_added : TIMESTAMPTZ
modified_at : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' CONFIGURATION
' ══════════════════════════════════════════════════════════════
package "Configuration" #FFF3E0 {
entity "quality_profiles" {
* id : UUID <<PK>>
--
name : TEXT <<UNIQUE>>
cutoff : INT
items : JSONB
upgrade_allowed : BOOLEAN
min_format_score : INT
cutoff_format_score : INT
}
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>>
default_monitor_option : TEXT
default_tags : INT[]
}
entity "tags" {
* id : SERIAL <<PK>>
--
label : TEXT <<UNIQUE>>
}
entity "indexers" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
enable_rss : BOOLEAN
enable_search : BOOLEAN
priority : INT
tags : INT[]
}
entity "download_clients" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
protocol : TEXT
priority : INT
remove_completed : BOOLEAN
remove_failed : BOOLEAN
tags : INT[]
enabled : BOOLEAN
}
}
' ══════════════════════════════════════════════════════════════
' DOWNLOAD TRACKING
' ══════════════════════════════════════════════════════════════
package "Download Tracking" #E8F5E9 {
entity "download_queue" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
download_id : TEXT
title : TEXT
size : BIGINT
size_left : BIGINT
time_left : INTERVAL
estimated_completion : TIMESTAMPTZ
--
status : TEXT
state : TEXT
status_messages : JSONB
--
protocol : TEXT
indexer : TEXT
download_client : TEXT
output_path : TEXT
download_forced : BOOLEAN
--
added_at : TIMESTAMPTZ
}
entity "pending_releases" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
title : TEXT
release : JSONB
parsed_album_info : JSONB
reason : TEXT
additional_info : JSONB
--
added_at : TIMESTAMPTZ
}
entity "download_history" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
event_type : TEXT
download_id : TEXT
source_title : TEXT
protocol : TEXT
indexer_id : UUID
download_client_id : UUID
release : JSONB
data : JSONB
--
date : TIMESTAMPTZ
}
entity "blocklist" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_ids : UUID[]
--
source_title : TEXT
quality : JSONB
size : BIGINT
protocol : TEXT
indexer : TEXT
message : TEXT
torrent_hash : TEXT
--
published_date : TIMESTAMPTZ
date : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' HISTORY & TRACKING
' ══════════════════════════════════════════════════════════════
package "History & Tracking" #FCE4EC {
entity "history" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
track_id : UUID <<FK NULL>>
--
event_type : TEXT
source_title : TEXT
quality : JSONB
download_id : TEXT
data : JSONB
--
date : TIMESTAMPTZ
}
entity "wanted_albums" {
' View/materialized view for missing albums
* album_id : UUID <<FK>>
--
artist_id : UUID
title : TEXT
release_date : DATE
monitored : BOOLEAN
track_count : INT
track_file_count : INT
}
}
' ══════════════════════════════════════════════════════════════
' RELATIONSHIPS
' ══════════════════════════════════════════════════════════════
' 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"
' Artist relationships
artists }o--|| quality_profiles : "uses"
artists }o--o| metadata_profiles : "uses"
artists }o--o| root_folders : "stored in"
' Configuration relationships
root_folders }o--o| quality_profiles : "default"
root_folders }o--o| metadata_profiles : "default"
' Download tracking relationships
download_queue }o--o| artists : "for"
download_queue }o--o| albums : "for"
pending_releases }o--|| artists : "for"
pending_releases }o--o| albums : "for"
download_history }o--|| artists : "for"
download_history }o--o| albums : "for"
blocklist }o--|| artists : "for"
' History relationships
history }o--|| artists : "for"
history }o--o| albums : "for"
history }o--o| tracks : "for"
@enduml
-93
View File
@@ -1,93 +0,0 @@
# Lidarr Database Research Summary
## Overview
Lidarr is a music collection manager that uses a **Release-based** system. Key design principles:
1. **Metadata Separation** - Artist metadata separated from configuration
2. **Release-Centric** - Works with releases, not loose tracks
3. **Monitoring Hierarchy** - Artist → Album → Release (only one release monitored per album)
4. **Quality Profiles** - Separate profiles for quality and metadata preferences
5. **Download Lifecycle** - Pending → Queue → Imported → History/Blocklist
## Core Entity Hierarchy
```
ArtistMetadata (1) ←→ (1) Artist (1) ←→ (N) Albums
↓
(N) AlbumReleases (only 1 monitored)
↓
(N) Tracks (N) ←→ (1) TrackFile
```
## Key Entities
### Music Entities
- **Artists** - Configuration (path, monitoring, profiles)
- **ArtistMetadata** - Metadata (name, images, genres, members)
- **Albums** - Album info with monitoring and search tracking
- **AlbumReleases** - Physical releases (CD, Vinyl, Digital) - only ONE monitored per album
- **Tracks** - Individual tracks linked to releases
- **TrackFiles** - Actual files on disk with quality info
### Configuration
- **QualityProfiles** - Acceptable formats and upgrade cutoff
- **MetadataProfiles** - Which album types to include (Studio, EP, Live, etc.)
- **RootFolders** - Storage locations with defaults
### Download Tracking
- **PendingReleases** - Delayed downloads (waiting for better quality)
- **DownloadHistory** - Download lifecycle events
- **Blocklist** - Failed/rejected releases (prevent re-download)
- **History** - Complete audit trail of all events
### System
- **Indexers** - Search sources (Newznab/Torznab)
- **DownloadClients** - Torrent/Usenet clients
- **ImportLists** - Auto-import from Spotify, Last.fm, etc.
- **Tags** - Categorization
## Monitoring States
### Artist Level
- `monitored` - Is artist being tracked
- `monitor_new_items` - Policy for new releases (All/Future/Missing/Existing/None)
### Album Level
- `monitored` - Should we look for this album
- `any_release_ok` - Auto-switch releases during import
### Release Level
- `monitored` - Is this the release we want (exactly ONE per album)
## Download States
```
TrackedDownloadState:
Downloading → ImportPending → Importing → Imported
→ Ignored
→ DownloadFailed → DownloadFailedPending
```
## History Event Types
- Grabbed - Download started
- TrackFileImported - File imported to library
- DownloadFailed - Download failed
- TrackFileDeleted - File removed
- TrackFileRenamed - File renamed
- TrackFileRetagged - Metadata updated
- AlbumImportIncomplete - Partial import
- DownloadIgnored - Download skipped
## Sources
- GitHub: Lidarr/Lidarr
- Key files:
- `src/NzbDrone.Core/Music/Model/Artist.cs`
- `src/NzbDrone.Core/Music/Model/Album.cs`
- `src/NzbDrone.Core/Music/Model/Release.cs` (AlbumRelease)
- `src/NzbDrone.Core/Music/Model/Track.cs`
- `src/NzbDrone.Core/MediaFiles/TrackFile.cs`
- `src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs`
- `src/NzbDrone.Core/Datastore/Migration/023_add_release_groups_etc.cs`
Generated
+26 -32
View File
@@ -21,11 +21,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1777988971,
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -37,15 +37,14 @@
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1776796298,
"narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=",
"lastModified": 1782908218,
"narHash": "sha256-wLMOrPgVyeF3XmP+qfYcLqnVdTxikdcSvbIY7rA9jTA=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
"rev": "9f7e99119ece7705299595299f3b031f39356de1",
"type": "github"
},
"original": {
@@ -54,27 +53,6 @@
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1770073757,
@@ -108,11 +86,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1778095059,
"narHash": "sha256-LW2nru9+O0oyR3lfzgzFLwTibhINoIL/dx2/1dBMKWU=",
"lastModified": 1782929431,
"narHash": "sha256-Ys6O7G+rrgcvhyuEER7KkFjhAXXHpFCmsBzVaRFYe1s=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "94a37dc9da62b41f0c70a91da739bc318d049c11",
"rev": "1b56c1483c5fd996da3180b7f2023ec8d2c8cc0f",
"type": "github"
},
"original": {
@@ -125,7 +103,23 @@
"inputs": {
"flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs_2"
"nixpkgs": "nixpkgs_2",
"vpnconfinement": "vpnconfinement"
}
},
"vpnconfinement": {
"locked": {
"lastModified": 1778182451,
"narHash": "sha256-Bz3n2THDGf90Z9gMqhH/J492prYH8B6RFRlxv/fPBwc=",
"owner": "Maroka-chan",
"repo": "VPN-Confinement",
"rev": "cf5bfc4c3559f2e783698b1aa23c165072039a7d",
"type": "github"
},
"original": {
"owner": "Maroka-chan",
"repo": "VPN-Confinement",
"type": "github"
}
}
},
+24
View File
@@ -3,6 +3,7 @@
nixpkgs.url = "github:nixos/nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts";
git-hooks.url = "github:cachix/git-hooks.nix";
vpnconfinement.url = "github:Maroka-chan/VPN-Confinement";
};
outputs =
@@ -18,6 +19,18 @@
"x86_64-linux"
];
flake = {
nixosModules = {
agregators = {
imports = [
./nix/default.nix
inputs.vpnconfinement.nixosModules.default
];
};
default = self.nixosModules.agregators;
};
};
perSystem =
{
system,
@@ -36,12 +49,22 @@
gofmt.enable = true;
};
};
music-agregator = pkgs.callPackage ./package.nix { };
in
{
formatter = pkgs.nixfmt-tree;
packages = {
default = music-agregator;
music-agregator = music-agregator;
};
checks = {
inherit pre-commit-check;
simple-test = pkgs.callPackage ./tests/simple-test.nix {
nixosModules = self.nixosModules;
};
};
devShells.default = pkgs.mkShell {
@@ -60,6 +83,7 @@
protobuf
protoc-gen-go
protoc-gen-go-grpc
grpcurl
go
gopls
-168
View File
@@ -1,168 +0,0 @@
module homelab.lan/music-agregator
go 1.26.2
require github.com/rs/zerolog v1.35.1
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/torrent v1.61.0 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
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/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/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/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_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/riverqueue/river v0.35.1 // indirect
github.com/riverqueue/river/riverdriver v0.35.1 // indirect
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1 // indirect
github.com/riverqueue/river/rivershared v0.35.1 // indirect
github.com/riverqueue/river/rivertype v0.35.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/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/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/libc v1.22.3 // indirect
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
)
-649
View File
@@ -1,649 +0,0 @@
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=
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/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/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
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/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/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/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/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-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/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/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/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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/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/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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.14.1/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/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/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/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.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-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.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-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-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-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/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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
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.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=
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=
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
-76
View File
@@ -1,76 +0,0 @@
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"`
} `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
}
-137
View File
@@ -1,137 +0,0 @@
package database
import (
"context"
"fmt"
"time"
"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) 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
@@ -1,100 +0,0 @@
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
@@ -1,40 +0,0 @@
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()
}
@@ -1,104 +0,0 @@
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
}
-156
View File
@@ -1,156 +0,0 @@
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) 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) 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
@@ -1,104 +0,0 @@
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
}
-68
View File
@@ -1,68 +0,0 @@
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
}
-32
View File
@@ -1,32 +0,0 @@
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
@@ -1,79 +0,0 @@
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
@@ -1,71 +0,0 @@
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
@@ -1,90 +0,0 @@
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
@@ -1,5 +0,0 @@
package indexer
type Filter interface {
IsKnownCategory(categories []string) bool
}
-8
View File
@@ -1,8 +0,0 @@
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
@@ -1,103 +0,0 @@
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
@@ -1,139 +0,0 @@
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
}
-142
View File
@@ -1,142 +0,0 @@
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/rutracker"
)
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 (
rutrackerParserFactory = rutracker.NewRuTrackerParserFactory()
)
func (sr *SearchResult) ToSearchResponse() *SearchResponse {
var items []*SearchItemResult
for _, item := range sr.Items {
rel := rutrackerParserFactory.GetParser(item.Categories).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
@@ -1,67 +0,0 @@
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
@@ -1,66 +0,0 @@
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
}
-24
View File
@@ -1,24 +0,0 @@
package metadata
import (
"fmt"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "homelab.lan/music-agregator/gen/metadata/v1"
)
func NewMetadataClient(endpoint string) (pb.MetadataServiceClient, *grpc.ClientConn, error) {
log.Trace().Str("endpoint", endpoint).Msg("connecting to metadata service")
conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, nil, fmt.Errorf("connecting to metadata service: %w", err)
}
log.Info().Str("endpoint", endpoint).Msg("metadata service connected")
return pb.NewMetadataServiceClient(conn), conn, nil
}
-77
View File
@@ -1,77 +0,0 @@
package metadata
import (
"context"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
pb "homelab.lan/music-agregator/gen/metadata/v1"
"homelab.lan/music-agregator/internal/config"
)
type MetadataServer struct {
client pb.MetadataServiceClient
conn *grpc.ClientConn
pb.UnimplementedMetadataServiceServer
}
func NewMetadataServer(cfg config.Config) (*MetadataServer, error) {
client, conn, err := NewMetadataClient(cfg.Metadata.Endpoint)
if err != nil {
log.Err(err).Msg("failed to initialize MetadataServer")
return nil, err
}
return &MetadataServer{client: client, conn: conn}, nil
}
func (s *MetadataServer) Close() {
if s.conn != nil {
s.conn.Close()
}
}
func (s *MetadataServer) Register(server *grpc.Server) {
pb.RegisterMetadataServiceServer(server, s)
}
func (s *MetadataServer) GetArtist(ctx context.Context, req *pb.GetArtistRequest) (*pb.GetArtistResponse, error) {
log.Debug().Msg("metadata GetArtist")
return s.client.GetArtist(ctx, req)
}
func (s *MetadataServer) SearchArtists(ctx context.Context, req *pb.SearchArtistsRequest) (*pb.SearchArtistsResponse, error) {
log.Debug().Str("query", req.GetQuery()).Int32("limit", req.GetLimit()).Msg("metadata SearchArtists")
return s.client.SearchArtists(ctx, req)
}
func (s *MetadataServer) GetAlbum(ctx context.Context, req *pb.GetAlbumRequest) (*pb.GetAlbumResponse, error) {
log.Debug().Msg("metadata GetAlbum")
return s.client.GetAlbum(ctx, req)
}
func (s *MetadataServer) GetArtistAlbums(ctx context.Context, req *pb.GetArtistAlbumsRequest) (*pb.GetArtistAlbumsResponse, error) {
log.Debug().Str("artist_id", req.GetArtistId()).Msg("metadata GetArtistAlbums")
return s.client.GetArtistAlbums(ctx, req)
}
func (s *MetadataServer) GetTrack(ctx context.Context, req *pb.GetTrackRequest) (*pb.GetTrackResponse, error) {
log.Debug().Msg("metadata GetTrack")
return s.client.GetTrack(ctx, req)
}
func (s *MetadataServer) GetAlbumTracks(ctx context.Context, req *pb.GetAlbumTracksRequest) (*pb.GetAlbumTracksResponse, error) {
log.Debug().Str("album_id", req.GetAlbumId()).Msg("metadata GetAlbumTracks")
return s.client.GetAlbumTracks(ctx, req)
}
func (s *MetadataServer) SearchAlbums(ctx context.Context, req *pb.SearchAlbumsRequest) (*pb.SearchAlbumsResponse, error) {
log.Debug().Str("query", req.GetQuery()).Str("artist", req.GetArtist()).Msg("metadata SearchAlbums")
return s.client.SearchAlbums(ctx, req)
}
func (s *MetadataServer) SyncArtist(ctx context.Context, req *pb.SyncArtistRequest) (*pb.SyncArtistResponse, error) {
log.Debug().Msg("metadata SyncArtist")
return s.client.SyncArtist(ctx, req)
}
-177
View File
@@ -1,177 +0,0 @@
package metadata
import (
"context"
"fmt"
"github.com/rs/zerolog/log"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
"homelab.lan/music-agregator/internal/database"
)
type MetadataService struct {
client metadataPb.MetadataServiceClient
artists *database.ArtistRepository
albums *database.AlbumRepository
tracks *database.TrackRepository
}
func NewMetadataService(client metadataPb.MetadataServiceClient, db *database.DB) *MetadataService {
return &MetadataService{
client: client,
artists: database.NewArtistRepository(db.Pool),
albums: database.NewAlbumRepository(db.Pool),
tracks: database.NewTrackRepository(db.Pool),
}
}
func (s *MetadataService) GetAlbum(ctx context.Context, albumID string) (*metadataPb.Album, error) {
resp, err := s.client.GetAlbum(ctx, &metadataPb.GetAlbumRequest{
Identifier: &metadataPb.GetAlbumRequest_Id{Id: albumID},
})
if err != nil {
return nil, fmt.Errorf("fetching album: %w", err)
}
album := resp.GetAlbum()
if _, err := s.albums.GetByExternalID(ctx, album.GetId()); err != nil {
s.PersistArtist(ctx, album, database.Monitored)
s.PersistAlbum(ctx, album, database.Monitored)
}
return album, nil
}
func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistExternalID string) ([]*metadataPb.Album, error) {
resp, err := s.client.GetArtistAlbums(ctx, &metadataPb.GetArtistAlbumsRequest{
ArtistId: artistExternalID,
})
if err != nil {
return nil, fmt.Errorf("fetching artist albums: %w", err)
}
return resp.GetAlbums(), nil
}
func (s *MetadataService) GetAlbumTracks(ctx context.Context, albumExternalID string) ([]*metadataPb.Track, error) {
resp, err := s.client.GetAlbumTracks(ctx, &metadataPb.GetAlbumTracksRequest{
AlbumId: albumExternalID,
})
if err != nil {
return nil, fmt.Errorf("fetching album tracks: %w", err)
}
return resp.GetTracks(), nil
}
func (s *MetadataService) GetArtistByExternalID(ctx context.Context, externalID string) (*database.Artist, error) {
return s.artists.GetByExternalID(ctx, externalID)
}
func (s *MetadataService) GetAlbumByID(ctx context.Context, id string) (*database.Album, error) {
return s.albums.GetByID(ctx, id)
}
func (s *MetadataService) GetAlbumByExternalID(ctx context.Context, externalID string) (*database.Album, error) {
return s.albums.GetByExternalID(ctx, externalID)
}
func (s *MetadataService) GetAlbumsByArtistID(ctx context.Context, artistID string) ([]*database.Album, error) {
return s.albums.GetByArtistID(ctx, artistID)
}
func (s *MetadataService) GetTracksByAlbumID(ctx context.Context, albumID string) ([]*database.Track, error) {
return s.tracks.GetByAlbumID(ctx, albumID)
}
func (s *MetadataService) SetAlbumMonitorState(ctx context.Context, id string, state database.MonitorState) error {
return s.albums.SetMonitorState(ctx, id, state)
}
func (s *MetadataService) PersistArtist(ctx context.Context, album *metadataPb.Album, state database.MonitorState) {
if len(album.GetArtists()) == 0 {
return
}
artist := album.GetArtists()[0].GetArtist()
var genres []string
for _, g := range artist.GetGenres() {
genres = append(genres, g.GetName())
}
err := s.artists.Create(ctx, &database.Artist{
ExternalID: artist.GetId(),
Name: artist.GetName(),
ArtistType: artist.GetArtistType(),
Country: artist.GetCountry(),
Genres: genres,
ImageURL: artist.GetImageUrl(),
MonitorState: state,
})
if err != nil {
log.Warn().Err(err).Str("name", artist.GetName()).Msg("failed to persist artist")
}
}
func (s *MetadataService) PersistAlbum(ctx context.Context, album *metadataPb.Album, state database.MonitorState) {
s.PersistAlbumForArtist(ctx, album, "", state)
}
func (s *MetadataService) PersistAlbumForArtist(ctx context.Context, album *metadataPb.Album, artistDBID string, state database.MonitorState) {
if artistDBID == "" {
if len(album.GetArtists()) > 0 {
a, err := s.artists.GetByExternalID(ctx, album.GetArtists()[0].GetArtist().GetId())
if err == nil {
artistDBID = a.ID
}
}
}
if artistDBID == "" {
log.Trace().Str("album", album.GetTitle()).Msg("skipping album persist, no artist in DB")
return
}
var genres []string
for _, g := range album.GetGenres() {
genres = append(genres, g.GetName())
}
labelName := ""
if album.GetLabel() != nil {
labelName = album.GetLabel().GetName()
}
err := s.albums.Create(ctx, &database.Album{
ExternalID: album.GetId(),
ArtistID: artistDBID,
Title: album.GetTitle(),
AlbumType: album.GetAlbumType(),
TotalTracks: int(album.GetTotalTracks()),
TotalDiscs: int(album.GetTotalDiscs()),
Label: labelName,
Genres: genres,
CoverURL: album.GetCoverUrl(),
MonitorState: state,
})
if err != nil {
log.Warn().Err(err).Str("title", album.GetTitle()).Msg("failed to persist album")
}
}
func (s *MetadataService) PersistTracks(ctx context.Context, albumDBID string, tracks []*metadataPb.Track) {
for _, t := range tracks {
err := s.tracks.Create(ctx, &database.Track{
ExternalID: t.GetId(),
AlbumID: albumDBID,
Title: t.GetTitle(),
DurationMS: int(t.GetDurationMs()),
ISRC: t.GetIsrc(),
DiscNumber: int(t.GetDiscNumber()),
TrackNumber: int(t.GetTrackNumber()),
})
if err != nil {
log.Warn().Err(err).Str("title", t.GetTitle()).Msg("failed to persist track")
}
}
}
-7
View File
@@ -1,7 +0,0 @@
package internal
import "google.golang.org/grpc"
type Registrable interface {
Register(server *grpc.Server)
}
-218
View File
@@ -1,218 +0,0 @@
package release
import (
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
)
type Type int
const (
TypeUnknown Type = iota
TypeAlbum
TypeEP
TypeSingle
TypeDiscography
TypeCollection
TypeCompilation
TypeSoundtrack
TypeLive
TypeBootleg
)
func (t Type) String() string {
switch t {
case TypeAlbum:
return "album"
case TypeEP:
return "ep"
case TypeSingle:
return "single"
case TypeDiscography:
return "discography"
case TypeCollection:
return "collection"
case TypeCompilation:
return "compilation"
case TypeSoundtrack:
return "soundtrack"
case TypeLive:
return "live"
case TypeBootleg:
return "bootleg"
default:
return "unknown"
}
}
func (t Type) Priority() int {
switch t {
case TypeAlbum, TypeEP, TypeSingle:
return 1
case TypeLive:
return 2
case TypeSoundtrack:
return 3
case TypeCollection:
return 4
case TypeDiscography:
return 5
case TypeCompilation:
return 6
case TypeBootleg:
return 7
default:
return 99
}
}
type AudioFormat int
const (
FormatUnknown AudioFormat = iota
FormatFLAC
FormatMP3
FormatAAC
FormatAPE
FormatWavPack
FormatALAC
FormatOGG
FormatWAV
)
func (f AudioFormat) String() string {
switch f {
case FormatFLAC:
return "FLAC"
case FormatMP3:
return "MP3"
case FormatAAC:
return "AAC"
case FormatAPE:
return "APE"
case FormatWavPack:
return "WavPack"
case FormatALAC:
return "ALAC"
case FormatOGG:
return "OGG"
case FormatWAV:
return "WAV"
default:
return "unknown"
}
}
func (f AudioFormat) IsLossless() bool {
switch f {
case FormatFLAC, FormatAPE, FormatWavPack, FormatALAC, FormatWAV:
return true
default:
return false
}
}
type Source int
const (
SourceUnknown Source = iota
SourceCD
SourceWEB
SourceVinyl
SourceCassette
SourceDVD
SourceBluRay
)
func (s Source) String() string {
switch s {
case SourceCD:
return "CD"
case SourceWEB:
return "WEB"
case SourceVinyl:
return "Vinyl"
case SourceCassette:
return "Cassette"
case SourceDVD:
return "DVD"
case SourceBluRay:
return "BluRay"
default:
return "unknown"
}
}
type Release struct {
RawTitle string
Artist string
Album string
Year int
YearEnd int
Type Type
Genres []string
Format AudioFormat
Source Source
Bitrate string
BitDepth int
SampleRate int
RipType string
ReleaseCount int
Tags []string
Label string
CatalogNum string
InfoHash string
TrackCount int
TrackNames []string
AudioFileCount int
TotalAudioSize int64
HasCoverArt bool
HasCueSheet bool
HasRipLog bool
ParsedSuccessfully bool
ParseErrors []string
}
func (r *Release) IsDiscography() bool {
return r.Type == TypeDiscography || r.Type == TypeCollection
}
func (r *Release) IsSingleRelease() bool {
return r.Type == TypeAlbum || r.Type == TypeEP || r.Type == TypeSingle
}
func (r *Release) HasYearRange() bool {
return r.YearEnd > 0 && r.YearEnd != r.Year
}
func (r *Release) ToProto() *pb.Release {
if r == nil {
return nil
}
return &pb.Release{
RawTitle: r.RawTitle,
Artist: r.Artist,
Album: r.Album,
Year: int32(r.Year),
YearEnd: int32(r.YearEnd),
Type: r.Type.String(),
Genres: r.Genres,
Format: r.Format.String(),
Source: r.Source.String(),
Bitrate: r.Bitrate,
BitDepth: int32(r.BitDepth),
SampleRate: int32(r.SampleRate),
RipType: r.RipType,
ReleaseCount: int32(r.ReleaseCount),
Tags: r.Tags,
Label: r.Label,
CatalogNum: r.CatalogNum,
ParsedSuccessfully: r.ParsedSuccessfully,
ParseErrors: r.ParseErrors,
}
}
-50
View File
@@ -1,50 +0,0 @@
package internal
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/v1"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
)
type MusicAgregatorServer struct {
service *MusicAgregatorService
pb.UnimplementedMusicAgregatorServiceServer
}
func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorServer, error) {
service, err := NewMusicAgregatorService(cfg, riverClient, db)
if err != nil {
log.Err(err).Msg("failed to create MusicAgregatorService")
return nil, err
}
return &MusicAgregatorServer{
service: service,
}, nil
}
func NewMusicAgregatorServerWithService(service *MusicAgregatorService) *MusicAgregatorServer {
return &MusicAgregatorServer{service: service}
}
func (s *MusicAgregatorServer) GetArtists(ctx context.Context, req *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) {
return s.service.GetArtists(ctx, req)
}
func (s *MusicAgregatorServer) GetAlbum(ctx context.Context, req *pb.GetAlbumRequest) (*pb.GetAlbumResponse, error) {
return s.service.GetAlbum(ctx, req)
}
func (s *MusicAgregatorServer) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
return s.service.MonitorAlbum(ctx, req)
}
func (s *MusicAgregatorServer) Register(server *grpc.Server) {
pb.RegisterMusicAgregatorServiceServer(server, s)
}
-715
View File
@@ -1,715 +0,0 @@
package internal
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/indexer"
"homelab.lan/music-agregator/internal/metadata"
"homelab.lan/music-agregator/internal/release"
"homelab.lan/music-agregator/internal/torrent"
torrentParser "homelab.lan/music-agregator/internal/tracker"
"homelab.lan/music-agregator/internal/workers"
)
type parsedItem struct {
item *indexer.SearchItemResult
rel *release.Release
torrentData []byte
}
type MusicAgregatorService struct {
config config.Config
metadata *metadata.MetadataService
indexer indexer.Searcher
torrentClient torrent.TorrentClient
magnetResolver torrentParser.Resolver
riverClient *river.Client[pgx.Tx]
torrents *database.TorrentRepository
downloads *database.DownloadRepository
artists *database.ArtistRepository
downloadFiles *database.DownloadFileRepository
}
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorService, error) {
idx, err := indexer.NewIndexerService(cfg, riverClient, nil)
if err != nil {
log.Err(err).Msg("failed to create IndexerService")
return nil, err
}
metadataClient, _, err := metadata.NewMetadataClient(cfg.Metadata.Endpoint)
if err != nil {
log.Err(err).Msg("failed to create metadata client")
return nil, err
}
magnetResolver, err := torrentParser.NewMagnetResolver(30 * time.Second)
if err != nil {
log.Err(err).Msg("failed to create magnet resolver")
return nil, err
}
torrentClient, err := torrent.NewTorrentClient(cfg)
if err != nil {
log.Err(err).Msg("failed to create torrent client")
return nil, err
}
return &MusicAgregatorService{
config: cfg,
metadata: metadata.NewMetadataService(metadataClient, db),
indexer: idx,
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
}, nil
}
func NewMusicAgregatorServiceWithDeps(
metadata *metadata.MetadataService,
searcher indexer.Searcher,
torrentClient torrent.TorrentClient,
magnetResolver torrentParser.Resolver,
riverClient *river.Client[pgx.Tx],
db *database.DB,
) *MusicAgregatorService {
return &MusicAgregatorService{
metadata: metadata,
indexer: searcher,
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
}
}
func (s *MusicAgregatorService) Close() {
if closer, ok := s.magnetResolver.(interface{ Close() }); ok {
closer.Close()
}
}
func (service *MusicAgregatorService) GetArtists(ctx context.Context, _ *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) {
dbArtists, err := service.artists.GetAll(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to list artists")
return nil, fmt.Errorf("listing artists: %w", err)
}
artists := make([]*pb.ArtistSummary, 0, len(dbArtists))
for _, a := range dbArtists {
albums, err := service.buildAlbumsForArtist(ctx, a)
if err != nil {
log.Warn().Err(err).Str("artist", a.Name).Msg("failed to build album details, returning artist without albums")
}
artists = append(artists, &pb.ArtistSummary{
Id: a.ID,
ExternalId: a.ExternalID,
Name: a.Name,
ArtistType: a.ArtistType,
Country: a.Country,
Genres: a.Genres,
ImageUrl: a.ImageURL,
MonitorState: toProtoMonitorState(a.MonitorState),
Albums: albums,
})
}
return &pb.GetArtistsResponse{Artists: artists}, nil
}
func (service *MusicAgregatorService) buildAlbumsForArtist(ctx context.Context, artist *database.Artist) ([]*pb.AlbumDetail, error) {
metadataAlbums, err := service.metadata.GetArtistAlbums(ctx, artist.ExternalID)
if err != nil {
return nil, fmt.Errorf("fetching metadata albums: %w", err)
}
for _, ma := range metadataAlbums {
service.metadata.PersistAlbumForArtist(ctx, ma, artist.ID, database.Unmonitored)
}
dbAlbums, err := service.metadata.GetAlbumsByArtistID(ctx, artist.ID)
if err != nil {
log.Warn().Err(err).Str("artist_id", artist.ID).Msg("failed to get local albums")
dbAlbums = nil
}
dbAlbumsByExternalID := make(map[string]*database.Album, len(dbAlbums))
for _, a := range dbAlbums {
dbAlbumsByExternalID[a.ExternalID] = a
}
albums := make([]*pb.AlbumDetail, 0, len(metadataAlbums))
for _, ma := range metadataAlbums {
detail := &pb.AlbumDetail{
ExternalId: ma.GetId(),
Title: ma.GetTitle(),
AlbumType: ma.GetAlbumType(),
ReleaseDate: ma.GetReleaseDate(),
TotalTracks: ma.GetTotalTracks(),
TotalDiscs: ma.GetTotalDiscs(),
CoverUrl: ma.GetCoverUrl(),
}
if ma.GetLabel() != nil {
detail.Label = ma.GetLabel().GetName()
}
for _, g := range ma.GetGenres() {
detail.Genres = append(detail.Genres, g.GetName())
}
if dbAlbum, ok := dbAlbumsByExternalID[ma.GetId()]; ok {
detail.Id = dbAlbum.ID
detail.MonitorState = toProtoMonitorState(dbAlbum.MonitorState)
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
if err == nil && len(downloads) > 0 {
best := downloads[0]
detail.Download = &pb.DownloadInfo{
State: best.State,
Format: best.Format,
Quality: best.Quality,
SavePath: derefStr(best.SavePath),
}
}
} else {
detail.MonitorState = pb.MonitorState_MONITOR_STATE_UNMONITORED
}
albums = append(albums, detail)
}
return albums, nil
}
func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetAlbumRequest) (*pb.GetAlbumResponse, error) {
dbAlbum, err := service.metadata.GetAlbumByID(ctx, req.GetAlbumId())
if err != nil {
return nil, fmt.Errorf("album not found: %w", err)
}
metadataAlbum, err := service.metadata.GetAlbum(ctx, dbAlbum.ExternalID)
if err != nil {
log.Error().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get album from metadata")
return nil, fmt.Errorf("fetching album: %w", err)
}
metadataTracks, err := service.metadata.GetAlbumTracks(ctx, dbAlbum.ExternalID)
if err != nil {
log.Warn().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get tracks from metadata")
}
service.metadata.PersistTracks(ctx, dbAlbum.ID, metadataTracks)
album := &pb.AlbumDetail{
Id: dbAlbum.ID,
ExternalId: metadataAlbum.GetId(),
Title: metadataAlbum.GetTitle(),
AlbumType: metadataAlbum.GetAlbumType(),
ReleaseDate: metadataAlbum.GetReleaseDate(),
TotalTracks: metadataAlbum.GetTotalTracks(),
TotalDiscs: metadataAlbum.GetTotalDiscs(),
CoverUrl: metadataAlbum.GetCoverUrl(),
MonitorState: toProtoMonitorState(dbAlbum.MonitorState),
}
if metadataAlbum.GetLabel() != nil {
album.Label = metadataAlbum.GetLabel().GetName()
}
for _, g := range metadataAlbum.GetGenres() {
album.Genres = append(album.Genres, g.GetName())
}
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
if err == nil && len(downloads) > 0 {
best := downloads[0]
album.Download = &pb.DownloadInfo{
State: best.State,
Format: best.Format,
Quality: best.Quality,
SavePath: derefStr(best.SavePath),
}
}
var downloadFilesByTrackID map[string]*database.DownloadFile
if album.Download != nil {
files, err := service.downloadFiles.GetByDownloadID(ctx, downloads[0].ID)
if err == nil {
downloadFilesByTrackID = make(map[string]*database.DownloadFile, len(files))
for _, f := range files {
if f.TrackID != nil {
downloadFilesByTrackID[*f.TrackID] = f
}
}
}
}
dbTracks, _ := service.metadata.GetTracksByAlbumID(ctx, dbAlbum.ID)
dbTracksByExternalID := make(map[string]*database.Track, len(dbTracks))
for _, t := range dbTracks {
dbTracksByExternalID[t.ExternalID] = t
}
tracks := make([]*pb.TrackDetail, 0, len(metadataTracks))
for _, mt := range metadataTracks {
td := &pb.TrackDetail{
ExternalId: mt.GetId(),
Title: mt.GetTitle(),
DurationMs: mt.GetDurationMs(),
DiscNumber: mt.GetDiscNumber(),
TrackNumber: mt.GetTrackNumber(),
Isrc: mt.GetIsrc(),
Explicit: mt.GetExplicit(),
}
for _, ac := range mt.GetArtists() {
td.Artists = append(td.Artists, &pb.ArtistCredit{
Id: ac.GetArtist().GetId(),
Name: ac.GetArtist().GetName(),
})
}
if dbTrack, ok := dbTracksByExternalID[mt.GetId()]; ok {
td.Id = dbTrack.ID
if df, ok := downloadFilesByTrackID[dbTrack.ID]; ok {
td.File = &pb.TrackFile{
Path: df.FilePath,
Format: df.FileType,
Size: df.FileSize,
}
}
}
tracks = append(tracks, td)
}
return &pb.GetAlbumResponse{
Album: album,
Tracks: tracks,
}, nil
}
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId())
if err != nil {
log.Error().Err(err).Str("album_id", req.GetAlbumId()).Msg("failed to get album")
return nil, err
}
dbAlbum, _ := service.metadata.GetAlbumByExternalID(ctx, album.GetId())
if dbAlbum != nil {
service.metadata.SetAlbumMonitorState(ctx, dbAlbum.ID, database.Monitored)
dbAlbum.MonitorState = database.Monitored
qualityStr := normalizeQuality(req.GetQuality(), 0, 0)
owned, err := service.downloads.HasAlbumInQuality(ctx, dbAlbum.ID, req.GetQuality().String(), qualityStr)
if err == nil && owned {
log.Info().Str("album", dbAlbum.Title).Str("quality", qualityStr).Msg("album already owned in requested quality")
return service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil), nil
}
}
searchResult, err := service.searchIndexer(album, req.GetIndexerOptions().GetTracker())
if err != nil {
return nil, err
}
parsed := service.parseSearchResults(searchResult, album)
filtered := filterByQuality(parsed, req.GetQuality())
if len(filtered) == 0 {
log.Warn().Str("album", album.GetTitle()).Str("quality", req.GetQuality().String()).Msg("no releases match quality filter")
return service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil), nil
}
best := selectBestRelease(filtered)
if err := service.addToTorrentClient(best); err != nil {
return nil, err
}
dbAlbum, _ = service.metadata.GetAlbumByExternalID(ctx, album.GetId())
if dbAlbum != nil {
service.saveTorrentAndDownload(ctx, dbAlbum.ID, best)
} else {
log.Warn().Str("album_id", req.GetAlbumId()).Msg("album not in DB, skipping torrent/download persistence")
}
return service.buildMonitorAlbumResponse(ctx, album, dbAlbum, &best), nil
}
func (service *MusicAgregatorService) searchIndexer(album *metadataPb.Album, tracker string) (*indexer.SearchResponse, error) {
artistName := ""
if len(album.GetArtists()) > 0 {
artistName = album.GetArtists()[0].GetArtist().GetName()
}
query := album.GetTitle()
if artistName != "" {
query = artistName + " " + query
}
if tracker == "" {
tracker = "all"
}
result, err := service.indexer.Search(query, -1, tracker)
if err != nil {
log.Error().Err(err).Str("query", query).Msg("indexer search failed")
return nil, err
}
log.Debug().Int("results", len(result.Items)).Str("query", query).Msg("indexer search completed")
return result, nil
}
func (service *MusicAgregatorService) parseSearchResults(searchResult *indexer.SearchResponse, album *metadataPb.Album) []parsedItem {
parser := torrentParser.NewGenericParser()
var parsed []parsedItem
for _, item := range searchResult.Items {
if item.DownloadLink == "" {
log.Trace().Str("title", item.Title).Msg("skipping item without download link")
continue
}
if item.Seeders == 0 {
log.Warn().Str("title", item.Title).Str("tracker", item.Tracker).Msg("skipping torrent with no seeders")
continue
}
r, torrentData := service.resolveRelease(parser, item, album)
log.Debug().
Str("title", item.Title).
Str("format", r.Format.String()).
Int("tracks", r.TrackCount).
Bool("lossless", r.Format.IsLossless()).
Int("seeders", item.Seeders).
Str("tracker", item.Tracker).
Msg("release parsed")
parsed = append(parsed, parsedItem{item: item, rel: r, torrentData: torrentData})
}
log.Debug().Int("total", len(searchResult.Items)).Int("parsed", len(parsed)).Msg("parsing complete")
return parsed
}
func (service *MusicAgregatorService) resolveRelease(parser *torrentParser.GenericParser, item *indexer.SearchItemResult, album *metadataPb.Album) (*release.Release, []byte) {
if strings.HasPrefix(item.DownloadLink, "magnet:") {
log.Trace().Str("title", item.Title).Int("reported_seeders", item.Seeders).Msg("resolving magnet")
torrentData, err := service.magnetResolver.Resolve(item.DownloadLink)
if err != nil {
log.Warn().Err(err).Str("title", item.Title).Int("reported_seeders", item.Seeders).Msg("magnet resolve failed, falling back to title parse")
return parser.Parse(item.Title), nil
}
return parser.ParseTorrent(torrentData, album), torrentData
}
torrentData, err := downloadTorrentData(item.DownloadLink)
if err != nil {
log.Warn().Err(err).Str("title", item.Title).Msg("failed to download torrent, falling back to title parse")
return parser.Parse(item.Title), nil
}
return parser.ParseTorrent(torrentData, album), torrentData
}
func filterByQuality(items []parsedItem, quality pb.QualityType) []parsedItem {
var filtered []parsedItem
for _, p := range items {
match := quality == pb.QualityType_QUALITY_UNSPECIFIED ||
(quality == pb.QualityType_QUALITY_LOSSLESS && p.rel.Format.IsLossless()) ||
(quality == pb.QualityType_QUALITY_LOSSY && !p.rel.Format.IsLossless())
if !match {
log.Debug().Str("title", p.item.Title).Str("format", p.rel.Format.String()).Str("wanted", quality.String()).Msg("filtered out by quality")
continue
}
filtered = append(filtered, p)
}
return filtered
}
func selectBestRelease(items []parsedItem) parsedItem {
best := items[0]
for _, p := range items[1:] {
if p.item.Seeders > best.item.Seeders {
best = p
}
}
log.Info().
Str("title", best.item.Title).
Str("format", best.rel.Format.String()).
Int("seeders", best.item.Seeders).
Str("tracker", best.item.Tracker).
Str("hash", best.rel.InfoHash).
Msg("best release selected")
return best
}
func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error {
if best.rel.InfoHash != "" {
existing, err := service.torrentClient.Find(torrent.FindOptions{Hash: best.rel.InfoHash})
if err == nil && len(existing) > 0 {
log.Info().Str("hash", best.rel.InfoHash).Str("state", existing[0].State).Msg("torrent already exists in client")
return nil
}
}
if strings.HasPrefix(best.item.DownloadLink, "magnet:") {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add magnet to client")
return err
}
} else {
if len(best.torrentData) == 0 {
log.Error().Str("title", best.item.Title).Msg("no torrent data available")
return fmt.Errorf("no torrent data available for best release")
}
if err := service.torrentClient.AddTorrent(torrent.TorrentFile{
Filename: best.rel.Album + ".torrent",
Data: best.torrentData,
}); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add torrent to client")
return err
}
}
log.Info().Str("title", best.item.Title).Str("hash", best.rel.InfoHash).Msg("torrent added to client")
return nil
}
func (service *MusicAgregatorService) saveTorrentAndDownload(ctx context.Context, dbAlbumID string, best parsedItem) {
quality := normalizeQuality(pb.QualityType_QUALITY_UNSPECIFIED, best.rel.BitDepth, best.rel.SampleRate)
dbTorrent := &database.Torrent{
AlbumID: dbAlbumID,
InfoHash: best.rel.InfoHash,
Tracker: best.item.Tracker,
Title: best.item.Title,
Format: best.rel.Format.String(),
Quality: quality,
Source: best.rel.Source.String(),
BitDepth: best.rel.BitDepth,
SampleRate: best.rel.SampleRate,
Seeders: best.item.Seeders,
Peers: best.item.Peers,
Size: best.rel.TotalAudioSize,
TrackCount: best.rel.TrackCount,
HasCoverArt: best.rel.HasCoverArt,
HasCueSheet: best.rel.HasCueSheet,
HasRipLog: best.rel.HasRipLog,
DownloadLink: best.item.DownloadLink,
TorrentFile: best.torrentData,
}
if err := service.torrents.Create(ctx, dbTorrent); err != nil {
log.Error().Err(err).Str("hash", best.rel.InfoHash).Msg("failed to save torrent to DB")
return
}
savedTorrent, err := service.torrents.GetByInfoHash(ctx, best.rel.InfoHash)
if err != nil {
log.Error().Err(err).Msg("failed to retrieve saved torrent")
return
}
existingDownload, err := service.downloads.GetActiveByTorrentID(ctx, savedTorrent.ID)
if err == nil && existingDownload != nil {
log.Info().Str("hash", best.rel.InfoHash).Str("state", existingDownload.State).Msg("active download already exists, skipping")
return
}
download := &database.Download{
TorrentID: savedTorrent.ID,
AlbumID: dbAlbumID,
Format: best.rel.Format.String(),
Quality: quality,
State: "downloading",
QbitHash: best.rel.InfoHash,
}
if err := service.downloads.Create(ctx, download); err != nil {
log.Error().Err(err).Msg("failed to save download to DB")
return
}
if service.riverClient != nil {
_, err := service.riverClient.Insert(ctx, workers.PollDownloadArgs{
DownloadID: download.ID,
TorrentHash: best.rel.InfoHash,
CheckInterval: 30 * time.Second,
}, &river.InsertOpts{
ScheduledAt: time.Now().Add(30 * time.Second),
})
if err != nil {
log.Error().Err(err).Msg("failed to schedule download poll job")
} else {
log.Debug().Str("download_id", download.ID).Str("hash", best.rel.InfoHash).Msg("download poll job scheduled")
}
}
log.Info().Str("hash", best.rel.InfoHash).Str("download_id", download.ID).Msg("torrent and download saved to DB")
}
func normalizeQuality(quality pb.QualityType, bitDepth int, sampleRate int) string {
if bitDepth > 0 && sampleRate > 0 {
return fmt.Sprintf("%d-%d", bitDepth, sampleRate/1000)
}
switch quality {
case pb.QualityType_QUALITY_LOSSLESS:
return "16-44"
case pb.QualityType_QUALITY_LOSSY:
return "320"
default:
return ""
}
}
func buildMonitoredRelease(p parsedItem) *pb.MonitoredRelease {
return &pb.MonitoredRelease{
InfoHash: p.rel.InfoHash,
Artist: p.rel.Artist,
Album: p.rel.Album,
Year: int32(p.rel.Year),
Format: p.rel.Format.String(),
Lossless: p.rel.Format.IsLossless(),
BitDepth: int32(p.rel.BitDepth),
SampleRate: int32(p.rel.SampleRate),
Source: p.rel.Source.String(),
TrackCount: int32(p.rel.TrackCount),
TrackNames: p.rel.TrackNames,
HasCoverArt: p.rel.HasCoverArt,
HasCueSheet: p.rel.HasCueSheet,
HasRipLog: p.rel.HasRipLog,
TotalAudioSize: p.rel.TotalAudioSize,
DownloadLink: p.item.DownloadLink,
Seeders: int32(p.item.Seeders),
Tracker: p.item.Tracker,
}
}
func (service *MusicAgregatorService) buildMonitorAlbumResponse(ctx context.Context, metadataAlbum *metadataPb.Album, dbAlbum *database.Album, best *parsedItem) *pb.MonitorAlbumResponse {
resp := &pb.MonitorAlbumResponse{}
if best != nil {
resp.Release = buildMonitoredRelease(*best)
}
if dbAlbum != nil {
resp.Album = service.buildAlbumDetail(ctx, dbAlbum)
}
if len(metadataAlbum.GetArtists()) > 0 {
dbArtist, err := service.metadata.GetArtistByExternalID(ctx, metadataAlbum.GetArtists()[0].GetArtist().GetId())
if err == nil {
resp.Artist = &pb.ArtistSummary{
Id: dbArtist.ID,
ExternalId: dbArtist.ExternalID,
Name: dbArtist.Name,
ArtistType: dbArtist.ArtistType,
Country: dbArtist.Country,
Genres: dbArtist.Genres,
ImageUrl: dbArtist.ImageURL,
MonitorState: toProtoMonitorState(dbArtist.MonitorState),
}
}
}
return resp
}
func (service *MusicAgregatorService) buildAlbumDetail(ctx context.Context, dbAlbum *database.Album) *pb.AlbumDetail {
detail := &pb.AlbumDetail{
Id: dbAlbum.ID,
ExternalId: dbAlbum.ExternalID,
Title: dbAlbum.Title,
AlbumType: dbAlbum.AlbumType,
TotalTracks: int32(dbAlbum.TotalTracks),
TotalDiscs: int32(dbAlbum.TotalDiscs),
Label: dbAlbum.Label,
Genres: dbAlbum.Genres,
CoverUrl: dbAlbum.CoverURL,
MonitorState: toProtoMonitorState(dbAlbum.MonitorState),
}
if dbAlbum.ReleaseDate != nil {
detail.ReleaseDate = dbAlbum.ReleaseDate.Format("2006-01-02")
}
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
if err == nil && len(downloads) > 0 {
best := downloads[0]
detail.Download = &pb.DownloadInfo{
State: best.State,
Format: best.Format,
Quality: best.Quality,
SavePath: derefStr(best.SavePath),
}
}
return detail
}
func toProtoMonitorState(state database.MonitorState) pb.MonitorState {
switch state {
case database.Monitored:
return pb.MonitorState_MONITOR_STATE_MONITORED
case database.Unmonitored:
return pb.MonitorState_MONITOR_STATE_UNMONITORED
case database.Excluded:
return pb.MonitorState_MONITOR_STATE_EXCLUDED
default:
return pb.MonitorState_MONITOR_STATE_UNSPECIFIED
}
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func downloadTorrentData(url string) ([]byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("downloading torrent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("torrent download returned HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading torrent data: %w", err)
}
return data, nil
}
-48
View File
@@ -1,48 +0,0 @@
package torrent
type TorrentInfo struct {
Hash string
Name string
Size int64
Progress float64
DlSpeed int64
UpSpeed int64
NumSeeds int32
NumLeechs int32
State string
ETA int64
Ratio float64
Category string
Tags string
AddedOn int64
CompletionOn int64
SavePath string
ContentPath string
Downloaded int64
Uploaded int64
Tracker string
SeedingTime int64
AmountLeft int64
Availability float64
}
type TorrentFile struct {
Filename string
Data []byte
}
type FindOptions struct {
Hash string
Name string
Category string
Tag string
State string
}
type TorrentClient interface {
Login(username string, password string) (string, error)
List() ([]TorrentInfo, error)
Find(opts FindOptions) ([]TorrentInfo, error)
AddTorrent(file TorrentFile) error
AddMagnet(magnetURI string) error
}
-36
View File
@@ -1,36 +0,0 @@
package torrent
import (
"fmt"
"github.com/rs/zerolog/log"
"homelab.lan/music-agregator/internal/config"
)
func MustNewTorrentClient(cfg config.Config) TorrentClient {
client, err := NewTorrentClient(cfg)
if err != nil {
panic(fmt.Sprintf("failed to create torrent client: %v", err))
}
return client
}
func NewTorrentClient(cfg config.Config) (TorrentClient, error) {
var client TorrentClient
switch cfg.Torrent.ClientType {
case config.TorrentClientQbittorrent:
client = NewQbittorrentClient(cfg.Torrent.Url)
default:
return nil, fmt.Errorf("unknown torrent client type: %s", cfg.Torrent.ClientType)
}
if _, err := client.Login(cfg.Torrent.Username, cfg.Torrent.Password); err != nil {
return nil, fmt.Errorf("torrent client login failed: %w", err)
}
log.Info().Str("client", string(cfg.Torrent.ClientType)).Str("url", cfg.Torrent.Url).Msg("torrent client connected")
return client, nil
}
-305
View File
@@ -1,305 +0,0 @@
package torrent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
"github.com/rs/zerolog/log"
)
type QbittorrentClient struct {
baseURL string
client *http.Client
sid string
}
func NewQbittorrentClient(baseURL string) *QbittorrentClient {
return &QbittorrentClient{
baseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *QbittorrentClient) Login(username string, password string) (string, error) {
log.Trace().Str("url", c.baseURL).Str("username", username).Msg("qbittorrent login attempt")
data := url.Values{
"username": {username},
"password": {password},
}
start := time.Now()
resp, err := c.client.PostForm(c.baseURL+"/api/v2/auth/login", data)
if err != nil {
log.Error().Err(err).Str("url", c.baseURL).Msg("qbittorrent login request failed")
return "", fmt.Errorf("login request failed: %w", err)
}
defer resp.Body.Close()
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent login response")
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("qbittorrent reading login response failed")
return "", fmt.Errorf("reading login response: %w", err)
}
if resp.StatusCode == http.StatusForbidden {
log.Warn().Msg("qbittorrent login forbidden: too many failed attempts")
return "", fmt.Errorf("login forbidden: too many failed attempts")
}
if string(body) != "Ok." {
log.Warn().Str("response", string(body)).Msg("qbittorrent login rejected")
return "", fmt.Errorf("login failed: %s", string(body))
}
for _, cookie := range resp.Cookies() {
if cookie.Name == "SID" {
c.sid = cookie.Value
log.Info().Str("url", c.baseURL).Msg("qbittorrent login successful")
log.Trace().Str("sid", c.sid).Msg("qbittorrent session ID acquired")
return c.sid, nil
}
}
log.Error().Msg("qbittorrent login succeeded but no SID cookie returned")
return "", fmt.Errorf("login succeeded but no SID cookie returned")
}
func (c *QbittorrentClient) List() ([]TorrentInfo, error) {
return c.Find(FindOptions{})
}
func (c *QbittorrentClient) Find(opts FindOptions) ([]TorrentInfo, error) {
log.Trace().
Str("hash", opts.Hash).
Str("name", opts.Name).
Str("category", opts.Category).
Str("tag", opts.Tag).
Str("state", opts.State).
Msg("qbittorrent finding torrents")
params := url.Values{}
if opts.Hash != "" {
params.Set("hashes", opts.Hash)
}
if opts.Category != "" {
params.Set("category", opts.Category)
}
if opts.Tag != "" {
params.Set("tag", opts.Tag)
}
if opts.State != "" {
params.Set("filter", opts.State)
}
reqURL := c.baseURL + "/api/v2/torrents/info"
if len(params) > 0 {
reqURL += "?" + params.Encode()
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
log.Error().Err(err).Msg("qbittorrent creating find request failed")
return nil, fmt.Errorf("creating find request: %w", err)
}
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
start := time.Now()
resp, err := c.client.Do(req)
if err != nil {
log.Error().Err(err).Msg("qbittorrent find request failed")
return nil, fmt.Errorf("find request failed: %w", err)
}
defer resp.Body.Close()
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent find response")
if resp.StatusCode != http.StatusOK {
log.Error().Int("status", resp.StatusCode).Msg("qbittorrent find returned non-OK status")
return nil, fmt.Errorf("find request returned status %d", resp.StatusCode)
}
var items []QbittorrentListItem
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
log.Error().Err(err).Msg("qbittorrent decoding find response failed")
return nil, fmt.Errorf("decoding find response: %w", err)
}
torrents := make([]TorrentInfo, len(items))
for i, item := range items {
torrents[i] = item.toTorrentInfo()
}
torrents = filterLocally(torrents, opts)
log.Debug().Int("count", len(torrents)).Msg("qbittorrent find results")
return torrents, nil
}
func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo {
var result []TorrentInfo
for _, t := range torrents {
if opts.Name != "" && !strings.Contains(strings.ToLower(t.Name), strings.ToLower(opts.Name)) {
continue
}
if opts.Hash != "" && !strings.EqualFold(t.Hash, opts.Hash) {
continue
}
if opts.Category != "" && !strings.EqualFold(t.Category, opts.Category) {
continue
}
if opts.Tag != "" && !strings.Contains(strings.ToLower(t.Tags), strings.ToLower(opts.Tag)) {
continue
}
if opts.State != "" && !strings.EqualFold(t.State, opts.State) {
continue
}
result = append(result, t)
}
return result
}
func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent file")
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("torrents", file.Filename)
if err != nil {
log.Error().Err(err).Msg("qbittorrent creating multipart form failed")
return fmt.Errorf("creating multipart form: %w", err)
}
if _, err := part.Write(file.Data); err != nil {
log.Error().Err(err).Msg("qbittorrent writing torrent data failed")
return fmt.Errorf("writing torrent data: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("closing multipart writer: %w", err)
}
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", &buf)
if err != nil {
log.Error().Err(err).Msg("qbittorrent creating add request failed")
return fmt.Errorf("creating add request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
return c.doAdd(req, file.Filename)
}
func (c *QbittorrentClient) AddMagnet(magnetURI string) error {
truncated := magnetURI
if len(truncated) > 80 {
truncated = truncated[:80] + "..."
}
log.Trace().Str("magnet", truncated).Msg("qbittorrent adding magnet")
data := url.Values{"urls": {magnetURI}}
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", strings.NewReader(data.Encode()))
if err != nil {
log.Error().Err(err).Msg("qbittorrent creating magnet add request failed")
return fmt.Errorf("creating magnet add request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
return c.doAdd(req, truncated)
}
func (c *QbittorrentClient) doAdd(req *http.Request, label string) error {
start := time.Now()
resp, err := c.client.Do(req)
if err != nil {
log.Error().Err(err).Msg("qbittorrent add request failed")
return fmt.Errorf("add request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("qbittorrent reading add response failed")
return fmt.Errorf("reading add response: %w", err)
}
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent add response")
if resp.StatusCode != http.StatusOK || string(body) != "Ok." {
log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("qbittorrent add failed")
return fmt.Errorf("add failed: status %d, body: %s", resp.StatusCode, string(body))
}
log.Info().Str("label", label).Msg("qbittorrent torrent added")
return nil
}
type QbittorrentListItem struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
Progress float64 `json:"progress"`
DlSpeed int64 `json:"dlspeed"`
UpSpeed int64 `json:"upspeed"`
NumSeeds int32 `json:"num_seeds"`
NumLeechs int32 `json:"num_leechs"`
State string `json:"state"`
ETA int64 `json:"eta"`
Ratio float64 `json:"ratio"`
Category string `json:"category"`
Tags string `json:"tags"`
AddedOn int64 `json:"added_on"`
CompletionOn int64 `json:"completion_on"`
SavePath string `json:"save_path"`
ContentPath string `json:"content_path"`
Downloaded int64 `json:"downloaded"`
Uploaded int64 `json:"uploaded"`
Tracker string `json:"tracker"`
SeedingTime int64 `json:"seeding_time"`
AmountLeft int64 `json:"amount_left"`
Availability float64 `json:"availability"`
}
func (t *QbittorrentListItem) toTorrentInfo() TorrentInfo {
return TorrentInfo{
Hash: t.Hash,
Name: t.Name,
Size: t.Size,
Progress: t.Progress,
DlSpeed: t.DlSpeed,
UpSpeed: t.UpSpeed,
NumSeeds: t.NumSeeds,
NumLeechs: t.NumLeechs,
State: t.State,
ETA: t.ETA,
Ratio: t.Ratio,
Category: t.Category,
Tags: t.Tags,
AddedOn: t.AddedOn,
CompletionOn: t.CompletionOn,
SavePath: t.SavePath,
ContentPath: t.ContentPath,
Downloaded: t.Downloaded,
Uploaded: t.Uploaded,
Tracker: t.Tracker,
SeedingTime: t.SeedingTime,
AmountLeft: t.AmountLeft,
Availability: t.Availability,
}
}
-46
View File
@@ -1,46 +0,0 @@
package torrent
import (
"context"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
"homelab.lan/music-agregator/internal/config"
)
type TorrentServer struct {
service *TorrentService
pb.UnimplementedTorrentServiceServer
}
func NewTorrentServer(cfg config.Config) (*TorrentServer, error) {
service, err := NewTorrentService(cfg)
if err != nil {
log.Err(err).Msg("failed to initialize TorrentService")
return nil, err
}
return &TorrentServer{service: service}, nil
}
func (server *TorrentServer) List(ctx context.Context, req *pb.ListRequest) (*pb.ListResponse, error) {
return server.service.List(req)
}
func (server *TorrentServer) Add(ctx context.Context, req *pb.AddRequest) (*pb.AddResponse, error) {
log.Debug().Str("download_url", req.GetDownloadUrl()).Str("filename", req.GetFilename()).Msg("add torrent requested")
resp, err := server.service.Add(req)
if err != nil {
log.Error().Err(err).Msg("add torrent failed")
return nil, err
}
return resp, nil
}
func (s *TorrentServer) Register(server *grpc.Server) {
pb.RegisterTorrentServiceServer(server, s)
}
-217
View File
@@ -1,217 +0,0 @@
package torrent
import (
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
"github.com/rs/zerolog/log"
pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1"
"homelab.lan/music-agregator/internal/config"
)
type TorrentService struct {
client TorrentClient
token string
}
func NewTorrentService(cfg config.Config) (*TorrentService, error) {
var client TorrentClient
switch cfg.Torrent.ClientType {
case config.TorrentClientQbittorrent:
client = NewQbittorrentClient(cfg.Torrent.Url)
default:
return nil, fmt.Errorf("unknown torrent client type: %s", cfg.Torrent.ClientType)
}
token, err := client.Login(cfg.Torrent.Username, cfg.Torrent.Password)
if err != nil {
return nil, fmt.Errorf("torrent client login failed: %w", err)
}
log.Info().Str("client", string(cfg.Torrent.ClientType)).Msg("torrent client connected")
return &TorrentService{
client: client,
token: token,
}, nil
}
func (service *TorrentService) List(req *pb.ListRequest) (*pb.ListResponse, error) {
torrents, err := service.client.List()
if err != nil {
return nil, err
}
return &pb.ListResponse{Items: toProtoItems(torrents)}, nil
}
func (service *TorrentService) Add(req *pb.AddRequest) (*pb.AddResponse, error) {
var file TorrentFile
if len(req.GetTorrentData()) > 0 {
file = TorrentFile{
Filename: req.GetFilename(),
Data: req.GetTorrentData(),
}
} else if req.GetDownloadUrl() != "" {
downloaded, err := downloadTorrentFile(req.GetDownloadUrl())
if err != nil {
return nil, err
}
file = *downloaded
} else {
return nil, fmt.Errorf("either torrent_data or download_url must be provided")
}
if err := service.client.AddTorrent(file); err != nil {
return nil, err
}
time.Sleep(500 * time.Millisecond)
searchName := strings.TrimSuffix(file.Filename, ".torrent")
torrents, err := service.client.Find(FindOptions{Name: searchName})
if err != nil {
log.Warn().Err(err).Msg("torrent added but failed to find it afterwards")
return &pb.AddResponse{}, nil
}
if len(torrents) == 0 {
log.Warn().Str("filename", file.Filename).Msg("torrent added but not found in client")
return &pb.AddResponse{}, nil
}
return &pb.AddResponse{Item: toProtoItem(torrents[0])}, nil
}
func toProtoItems(torrents []TorrentInfo) []*pb.ListItem {
items := make([]*pb.ListItem, len(torrents))
for i, t := range torrents {
items[i] = toProtoItem(t)
}
return items
}
func toProtoItem(t TorrentInfo) *pb.ListItem {
return &pb.ListItem{
Hash: t.Hash,
Name: t.Name,
Size: t.Size,
Progress: t.Progress,
Dlspeed: t.DlSpeed,
Upspeed: t.UpSpeed,
NumSeeds: t.NumSeeds,
NumLeechs: t.NumLeechs,
State: t.State,
Eta: t.ETA,
Ratio: t.Ratio,
Category: t.Category,
Tags: t.Tags,
AddedOn: t.AddedOn,
CompletionOn: t.CompletionOn,
SavePath: t.SavePath,
ContentPath: t.ContentPath,
Downloaded: t.Downloaded,
Uploaded: t.Uploaded,
Tracker: t.Tracker,
SeedingTime: t.SeedingTime,
AmountLeft: t.AmountLeft,
Availability: t.Availability,
SizeFormatted: formatBytes(t.Size),
ProgressFormatted: fmt.Sprintf("%.1f%%", t.Progress*100),
DlspeedFormatted: formatSpeed(t.DlSpeed),
UpspeedFormatted: formatSpeed(t.UpSpeed),
AddedOnFormatted: formatTimestamp(t.AddedOn),
CompletionOnFormatted: formatTimestamp(t.CompletionOn),
DownloadedFormatted: formatBytes(t.Downloaded),
UploadedFormatted: formatBytes(t.Uploaded),
AmountLeftFormatted: formatBytes(t.AmountLeft),
AvailabilityFormatted: fmt.Sprintf("%.2f", t.Availability),
EtaFormatted: formatETA(t.ETA),
}
}
func formatBytes(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.2f GB", float64(b)/float64(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
}
func formatSpeed(bytesPerSec int64) string {
if bytesPerSec == 0 {
return "0 B/s"
}
return formatBytes(bytesPerSec) + "/s"
}
func formatTimestamp(ts int64) string {
if ts <= 0 {
return ""
}
return time.Unix(ts, 0).Format("2006-01-02 15:04:05")
}
func formatETA(seconds int64) string {
if seconds <= 0 || seconds >= 8640000 {
return "∞"
}
d := time.Duration(seconds) * time.Second
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm %ds", h, m, s)
}
if m > 0 {
return fmt.Sprintf("%dm %ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
func downloadTorrentFile(url string) (*TorrentFile, error) {
log.Trace().Str("url", url).Msg("downloading torrent file")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
log.Error().Err(err).Str("url", url).Msg("downloading torrent file failed")
return nil, fmt.Errorf("downloading torrent file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Error().Int("status", resp.StatusCode).Str("url", url).Msg("torrent download returned non-OK status")
return nil, fmt.Errorf("torrent download returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading torrent file: %w", err)
}
filename := path.Base(resp.Request.URL.Path)
if !strings.HasSuffix(strings.ToLower(filename), ".torrent") {
filename += ".torrent"
}
log.Debug().Str("filename", filename).Int("size", len(data)).Msg("torrent file downloaded")
return &TorrentFile{
Filename: filename,
Data: data,
}, nil
}
-5
View File
@@ -1,5 +0,0 @@
package tracker
type ParserFactory interface {
GetParser(categories []string) Parser
}
-278
View File
@@ -1,278 +0,0 @@
package tracker
import (
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/anacrolix/torrent/metainfo"
"github.com/rs/zerolog/log"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
"homelab.lan/music-agregator/internal/release"
)
var (
bitratePattern = regexp.MustCompile(`(?i)(\d{2,3})\s*kbps`)
hiResPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)(\d{1,2})\s*[Bb]it\s*[-/]?\s*(\d{2,3}(?:\.\d)?)\s*[kK][Hh][Zz]`),
regexp.MustCompile(`(?i)\[?\s*(?:FLAC|Flac)\s+(\d{1,2})\s*[-/]\s*(\d{2,3}(?:\.\d)?)\s*\]?`),
regexp.MustCompile(`(?i)\[?\s*(\d{1,2})\s*[Bb]it\s*\]?`),
}
sourcePattern = regexp.MustCompile(`(?i)\[(CD|WEB|Vinyl|LP|Cassette|MC|DVD|Blu-?Ray|SACD|DAT)\]`)
ripTypePattern = regexp.MustCompile(`(?i)(vinyl\s*rip|SACD[- ]?R|HDCD|DSD\d*|tape\s*rip)`)
)
var audioExtensions = map[string]release.AudioFormat{
".flac": release.FormatFLAC,
".mp3": release.FormatMP3,
".aac": release.FormatAAC,
".m4a": release.FormatAAC,
".ape": release.FormatAPE,
".wv": release.FormatWavPack,
".alac": release.FormatALAC,
".ogg": release.FormatOGG,
".wav": release.FormatWAV,
}
type GenericParser struct{}
func NewGenericParser() *GenericParser {
return &GenericParser{}
}
func (p *GenericParser) ParseTorrent(torrentData []byte, album *metadataPb.Album) *release.Release {
r := &release.Release{}
p.fillFromMetadata(r, album)
p.fillFromTorrent(r, torrentData)
r.ParsedSuccessfully = r.Artist != "" && r.Album != ""
if !r.ParsedSuccessfully {
r.ParseErrors = append(r.ParseErrors, "missing artist or album")
}
return r
}
func (p *GenericParser) Parse(title string) *release.Release {
r := &release.Release{RawTitle: title}
p.fillFromTitle(r, title)
r.ParsedSuccessfully = r.Artist != "" && r.Album != ""
if !r.ParsedSuccessfully {
r.ParseErrors = append(r.ParseErrors, "missing artist or album")
}
return r
}
func (p *GenericParser) fillFromMetadata(r *release.Release, album *metadataPb.Album) {
if album == nil {
return
}
r.Album = album.GetTitle()
if len(album.GetArtists()) > 0 {
r.Artist = album.GetArtists()[0].GetArtist().GetName()
}
if album.GetReleaseDate() != "" {
if year, err := strconv.Atoi(album.GetReleaseDate()[:4]); err == nil {
r.Year = year
}
}
switch strings.ToLower(album.GetAlbumType()) {
case "album":
r.Type = release.TypeAlbum
case "ep":
r.Type = release.TypeEP
case "single":
r.Type = release.TypeSingle
case "compilation":
r.Type = release.TypeCompilation
case "soundtrack":
r.Type = release.TypeSoundtrack
case "live":
r.Type = release.TypeLive
}
for _, g := range album.GetGenres() {
r.Genres = append(r.Genres, g.GetName())
}
if album.GetLabel() != nil {
r.Label = album.GetLabel().GetName()
}
r.TrackCount = int(album.GetTotalTracks())
r.ReleaseCount = int(album.GetTotalDiscs())
log.Trace().
Str("artist", r.Artist).
Str("album", r.Album).
Int("year", r.Year).
Str("type", r.Type.String()).
Msg("filled from metadata")
}
func (p *GenericParser) fillFromTorrent(r *release.Release, torrentData []byte) {
if len(torrentData) == 0 {
return
}
mi, err := metainfo.Load(strings.NewReader(string(torrentData)))
if err != nil {
log.Error().Err(err).Msg("failed to parse torrent data")
r.ParseErrors = append(r.ParseErrors, "failed to parse torrent: "+err.Error())
return
}
info, err := mi.UnmarshalInfo()
if err != nil {
log.Error().Err(err).Msg("failed to unmarshal torrent info")
r.ParseErrors = append(r.ParseErrors, "failed to unmarshal torrent info: "+err.Error())
return
}
r.RawTitle = info.Name
r.InfoHash = mi.HashInfoBytes().HexString()
formatCounts := make(map[release.AudioFormat]int)
formatSizes := make(map[release.AudioFormat]int64)
if len(info.Files) == 0 {
ext := strings.ToLower(filepath.Ext(info.Name))
if fmt, ok := audioExtensions[ext]; ok {
r.Format = fmt
r.AudioFileCount = 1
r.TotalAudioSize = info.Length
}
} else {
for _, f := range info.Files {
path := filepath.Join(f.Path...)
ext := strings.ToLower(filepath.Ext(path))
name := strings.TrimSuffix(filepath.Base(path), ext)
if fmt, ok := audioExtensions[ext]; ok {
formatCounts[fmt]++
formatSizes[fmt] += f.Length
r.TrackNames = append(r.TrackNames, cleanTrackName(name))
}
switch ext {
case ".jpg", ".jpeg", ".png":
r.HasCoverArt = true
case ".cue":
r.HasCueSheet = true
case ".log":
r.HasRipLog = true
}
}
var dominantFormat release.AudioFormat
var maxCount int
for fmt, count := range formatCounts {
if count > maxCount {
maxCount = count
dominantFormat = fmt
}
}
r.Format = dominantFormat
r.AudioFileCount = maxCount
r.TotalAudioSize = formatSizes[dominantFormat]
}
if r.HasRipLog {
r.Source = release.SourceCD
}
if r.TrackCount == 0 {
r.TrackCount = r.AudioFileCount
}
p.fillFromTitle(r, info.Name)
log.Trace().
Str("hash", r.InfoHash).
Str("format", r.Format.String()).
Int("audio_files", r.AudioFileCount).
Int64("audio_size", r.TotalAudioSize).
Bool("cover", r.HasCoverArt).
Bool("cue", r.HasCueSheet).
Bool("log", r.HasRipLog).
Msg("filled from torrent")
}
func (p *GenericParser) fillFromTitle(r *release.Release, title string) {
if title == "" {
return
}
if m := bitratePattern.FindStringSubmatch(title); len(m) > 1 {
r.Bitrate = m[1] + " kbps"
}
for _, pattern := range hiResPatterns {
m := pattern.FindStringSubmatch(title)
if len(m) < 2 {
continue
}
if r.BitDepth == 0 {
if bd, err := strconv.Atoi(m[1]); err == nil {
r.BitDepth = bd
}
}
if len(m) > 2 && r.SampleRate == 0 {
if sr, err := strconv.ParseFloat(m[2], 64); err == nil {
r.SampleRate = int(sr * 1000)
}
}
if r.BitDepth > 0 {
break
}
}
if m := sourcePattern.FindStringSubmatch(title); len(m) > 1 && r.Source == release.SourceUnknown {
switch strings.ToUpper(m[1]) {
case "CD":
r.Source = release.SourceCD
case "WEB":
r.Source = release.SourceWEB
case "VINYL", "LP":
r.Source = release.SourceVinyl
case "CASSETTE", "MC":
r.Source = release.SourceCassette
case "DVD":
r.Source = release.SourceDVD
case "BLU-RAY", "BLURAY":
r.Source = release.SourceBluRay
}
}
if m := ripTypePattern.FindStringSubmatch(title); len(m) > 1 {
r.RipType = m[1]
}
log.Trace().
Str("bitrate", r.Bitrate).
Int("bit_depth", r.BitDepth).
Int("sample_rate", r.SampleRate).
Str("source", r.Source.String()).
Str("rip_type", r.RipType).
Msg("filled from title")
}
var trackNumberPrefix = regexp.MustCompile(`^\d{1,3}[\s.\-]+`)
func cleanTrackName(name string) string {
cleaned := trackNumberPrefix.ReplaceAllString(name, "")
if cleaned == "" {
return name
}
return strings.TrimSpace(cleaned)
}

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