Compare commits
51 Commits
8067cd93c5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e8b8153b6 | |||
| 69752bd6a2 | |||
| 93821ab214 | |||
| be859e87c0 | |||
| e5bdf2c4ce | |||
| 5a5660bf21 | |||
| 52e81faedd | |||
| 7582279077 | |||
| f52e9abb0a | |||
| 7d11b729a5 | |||
| eab92dd40b | |||
| ad03caa3f4 | |||
| 24f355c5ae | |||
| f5e2f764b5 | |||
| 6320f37240 | |||
| 2740585261 | |||
| 1e8506f146 | |||
| 7fa859e815 | |||
| ef75b9bfba | |||
| 758a4b909a | |||
| 31ec3f9826 | |||
| 6f31698006 | |||
| 3ce6e23421 | |||
| cca404bcc0 | |||
| 5257ed0f1b | |||
| 8c60fe5e35 | |||
| e61e58be72 | |||
| e49cc25372 | |||
| 60c94935b2 | |||
| 66264e1314 | |||
| 84a6fe8ec7 | |||
| 8ad2734964 | |||
| 79f3f145de | |||
| 2041c154cf | |||
| 97a57c10fd | |||
| 6071bc7980 | |||
| 36416081c1 | |||
| 3249bdc35c | |||
| 67f46f740b | |||
| 5fa46b2890 | |||
| b8fcbacb07 | |||
| 2400c6345a | |||
| b41ea7d023 | |||
| bfef1b6c79 | |||
| 8ffa92276e | |||
| 32eb8c931e | |||
| f8040ec088 | |||
| 268ee57913 | |||
| 6baa895c6c | |||
| 58aa4ef23b | |||
| 46c2f6541f |
@@ -4,3 +4,29 @@ config.yaml
|
|||||||
/server
|
/server
|
||||||
/vendor
|
/vendor
|
||||||
pkg/metadatapb/
|
pkg/metadatapb/
|
||||||
|
|
||||||
|
# Ignore all
|
||||||
|
*
|
||||||
|
|
||||||
|
# Unignore all with extensions
|
||||||
|
!*.*
|
||||||
|
|
||||||
|
# Unignore all dirs
|
||||||
|
!*/
|
||||||
|
|
||||||
|
### Above combination will ignore all files without extension ###
|
||||||
|
|
||||||
|
# Ignore files with extension `.class` & `.sm`
|
||||||
|
*.class
|
||||||
|
*.sm
|
||||||
|
|
||||||
|
# Ignore `bin` dir
|
||||||
|
bin/
|
||||||
|
# or
|
||||||
|
*/bin/*
|
||||||
|
|
||||||
|
# Ignore config.yaml
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Ignore protobuf generated files
|
||||||
|
gen/
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Analyze Album
|
||||||
|
type: grpc
|
||||||
|
seq: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.v1.MusicAgregatorService/AnalyzeAlbumRelease
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"album_id": "1d51e4a7-1a8b-4160-bd08-1aee658a991a"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Get 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"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Artist Albums
|
||||||
|
type: grpc
|
||||||
|
seq: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.v1.MusicAgregatorService/GetArtistAlbums
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"artist_id": "27e2997f-f7a1-4353-bcc4-57b9274fa9a4"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Artists
|
||||||
|
type: grpc
|
||||||
|
seq: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.v1.MusicAgregatorService/GetArtists
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
meta {
|
||||||
|
name: Hello Echo
|
||||||
|
type: grpc
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.hello.v1.HelloService/Echo
|
||||||
|
body: grpc
|
||||||
|
protoPath: ../proto/music_agregator/hello/v1/service.proto
|
||||||
|
auth: none
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"msg": "hello"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
meta {
|
||||||
|
name: Hello Ping
|
||||||
|
type: grpc
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:8081
|
||||||
|
method: /music_agregator.hello.v1.HelloService/Ping
|
||||||
|
body: grpc
|
||||||
|
protoPath: ../proto/music_agregator/hello/v1/service.proto
|
||||||
|
auth: none
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
meta {
|
||||||
|
name: Capabilities
|
||||||
|
type: grpc
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.indexer.v1.IndexerService/Capabilities
|
||||||
|
body: grpc
|
||||||
|
protoPath: ../proto/music_agregator/indexer/v1/indexer.proto
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"indexer": "rutracker"
|
||||||
|
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
meta {
|
||||||
|
name: Search
|
||||||
|
type: grpc
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.indexer.v1.IndexerService/Search
|
||||||
|
body: grpc
|
||||||
|
protoPath: ../proto/music_agregator/indexer/v1/indexer.proto
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"tracker": "",
|
||||||
|
"query": "Metallica",
|
||||||
|
"limit": 1
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Indexer
|
||||||
|
seq: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
meta {
|
||||||
|
name: Capabilities
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:9117/api/v2.0/indexers/rutracker/results/torznab/api?apikey=3jfvdvt1etzz36drkw5id5sb95sc47fi&t=caps
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:query {
|
||||||
|
apikey: 3jfvdvt1etzz36drkw5id5sb95sc47fi
|
||||||
|
t: caps
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
meta {
|
||||||
|
name: Search
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:9117/api/v2.0/indexers/all/results/torznab?apikey=3jfvdvt1etzz36drkw5id5sb95sc47fi&limit=2&q=Metallica&t=search
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:query {
|
||||||
|
apikey: 3jfvdvt1etzz36drkw5id5sb95sc47fi
|
||||||
|
limit: 2
|
||||||
|
q: Metallica
|
||||||
|
t: search
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Jackett
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Album
|
||||||
|
type: grpc
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /metadata.v1.MetadataService/GetAlbum
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"id": "a0b7b436-94db-4df6-8c5f-bc0e5932a90e"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Artist Albums
|
||||||
|
type: grpc
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /metadata.v1.MetadataService/GetArtistAlbums
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"artist_id": "65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Artist
|
||||||
|
type: grpc
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /metadata.v1.MetadataService/GetArtist
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"id": "65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
meta {
|
||||||
|
name: Search Albums
|
||||||
|
type: grpc
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /metadata.v1.MetadataService/SearchAlbums
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"query": "desidero",
|
||||||
|
"artist": "corrigo",
|
||||||
|
"limit": 770,
|
||||||
|
"offset": 396,
|
||||||
|
"provider": 0,
|
||||||
|
"album_types": [
|
||||||
|
"spiculum",
|
||||||
|
"spiculum"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: 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"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Metadata Agregator
|
||||||
|
seq: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
meta {
|
||||||
|
name: Monitor Album
|
||||||
|
type: grpc
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.v1.MusicAgregatorService/MonitorAlbum
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"album_id": "a0b7b436-94db-4df6-8c5f-bc0e5932a90e",
|
||||||
|
"indexer_options": {
|
||||||
|
"tracker": ""
|
||||||
|
},
|
||||||
|
"quality_type": 1
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: SearchArtists
|
||||||
|
type: grpc
|
||||||
|
seq: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc {
|
||||||
|
url: localhost:3000
|
||||||
|
method: /music_agregator.v1.MusicAgregatorService/SearchArtists
|
||||||
|
body: grpc
|
||||||
|
auth: inherit
|
||||||
|
methodType: unary
|
||||||
|
}
|
||||||
|
|
||||||
|
body:grpc {
|
||||||
|
name: message 1
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"query": "metal"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "Music Agregator",
|
||||||
|
"type": "collection",
|
||||||
|
"protobuf": {
|
||||||
|
"importPaths": [
|
||||||
|
{
|
||||||
|
"path": "../proto",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"protoFiles": [
|
||||||
|
{
|
||||||
|
"path": "../proto/music_agregator/hello/v1/service.proto",
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../proto/music_agregator/indexer/v1/indexer.proto",
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"requestType": "grpc",
|
||||||
|
"requestUrl": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
version: v2
|
||||||
|
inputs:
|
||||||
|
- directory: proto
|
||||||
|
- directory: ../metadata-agregator/proto
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-go
|
||||||
|
out: gen
|
||||||
|
opt: paths=source_relative
|
||||||
|
- local: protoc-gen-go-grpc
|
||||||
|
out: gen
|
||||||
|
opt: paths=source_relative
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
version: v2
|
||||||
|
modules:
|
||||||
|
- path: proto
|
||||||
|
lint:
|
||||||
|
use:
|
||||||
|
- STANDARD
|
||||||
|
breaking:
|
||||||
|
use:
|
||||||
|
- FILE
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
|
||||||
|
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/riverqueue/river"
|
||||||
|
"github.com/riverqueue/river/riverdriver/riverpgxv5"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
|
||||||
|
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
|
||||||
|
|
||||||
|
"homelab.lan/music-agregator/internal"
|
||||||
|
"homelab.lan/music-agregator/internal/analysis"
|
||||||
|
"homelab.lan/music-agregator/internal/config"
|
||||||
|
"homelab.lan/music-agregator/internal/database"
|
||||||
|
"homelab.lan/music-agregator/internal/hello"
|
||||||
|
"homelab.lan/music-agregator/internal/indexer"
|
||||||
|
"homelab.lan/music-agregator/internal/metadata"
|
||||||
|
"homelab.lan/music-agregator/internal/torrent"
|
||||||
|
"homelab.lan/music-agregator/internal/workers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).
|
||||||
|
With().Timestamp().Logger()
|
||||||
|
|
||||||
|
configPath := flag.String("config", "", "Path to the config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := parseConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to load config")
|
||||||
|
}
|
||||||
|
log.Info().Interface("config", cfg).Msg("Loaded config")
|
||||||
|
|
||||||
|
serveGrpc(*cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func interceptorLogger(l zerolog.Logger) logging.Logger {
|
||||||
|
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
|
||||||
|
l := l.With().Fields(fields).Logger()
|
||||||
|
switch lvl {
|
||||||
|
case logging.LevelDebug:
|
||||||
|
l.Debug().Msg(msg)
|
||||||
|
case logging.LevelInfo:
|
||||||
|
l.Info().Msg(msg)
|
||||||
|
case logging.LevelWarn:
|
||||||
|
l.Warn().Msg(msg)
|
||||||
|
case logging.LevelError:
|
||||||
|
l.Error().Msg(msg)
|
||||||
|
default:
|
||||||
|
l.Info().Msg(msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDatabase(ctx context.Context, cfg config.Config) *database.DB {
|
||||||
|
db, err := database.New(ctx, cfg.Database.URL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to connect to database")
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
type riverSetup struct {
|
||||||
|
client *river.Client[pgx.Tx]
|
||||||
|
cacheRefreshWorker *indexer.CacheRefreshWorker
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRiver(ctx context.Context, cfg config.Config, db *database.DB, torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper) *riverSetup {
|
||||||
|
cacheWorker := &indexer.CacheRefreshWorker{}
|
||||||
|
pollWorker := &workers.PollDownloadWorker{
|
||||||
|
Downloads: database.NewDownloadRepository(db.Pool),
|
||||||
|
DownloadFiles: database.NewDownloadFileRepository(db.Pool),
|
||||||
|
AlbumReleases: database.NewAlbumReleaseRepository(db.Pool),
|
||||||
|
TrackReleases: database.NewTrackReleaseRepository(db.Pool),
|
||||||
|
TorrentClient: torrentClient,
|
||||||
|
PathMapper: pathMapper,
|
||||||
|
Analyzer: analysis.NewReleaseAnalyzer(db),
|
||||||
|
}
|
||||||
|
|
||||||
|
riverWorkers := river.NewWorkers()
|
||||||
|
river.AddWorker(riverWorkers, cacheWorker)
|
||||||
|
river.AddWorker(riverWorkers, pollWorker)
|
||||||
|
|
||||||
|
riverClient, err := river.NewClient(riverpgxv5.New(db.Pool), &river.Config{
|
||||||
|
Queues: map[string]river.QueueConfig{
|
||||||
|
river.QueueDefault: {MaxWorkers: 4},
|
||||||
|
},
|
||||||
|
Workers: riverWorkers,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create River client")
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheWorker.RiverClient = riverClient
|
||||||
|
pollWorker.RiverClient = riverClient
|
||||||
|
|
||||||
|
if err := riverClient.Start(ctx); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to start River client")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msg("River queue started")
|
||||||
|
|
||||||
|
pollWorker.RecoverOrphanedDownloads(ctx)
|
||||||
|
|
||||||
|
return &riverSetup{
|
||||||
|
client: riverClient,
|
||||||
|
cacheRefreshWorker: cacheWorker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveGrpc(config config.Config) {
|
||||||
|
srvMetrics := grpcprom.NewServerMetrics(
|
||||||
|
grpcprom.WithServerHandlingTimeHistogram(
|
||||||
|
grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 10, 30}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logOpts := []logging.Option{
|
||||||
|
logging.WithLogOnEvents(logging.StartCall, logging.FinishCall),
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryOpts := []recovery.Option{
|
||||||
|
recovery.WithRecoveryHandler(func(p any) (err error) {
|
||||||
|
log.Error().Interface("panic", p).Msg("recovered from panic")
|
||||||
|
return status.Errorf(codes.Internal, "internal error")
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
server := grpc.NewServer(
|
||||||
|
grpc.ChainUnaryInterceptor(
|
||||||
|
srvMetrics.UnaryServerInterceptor(),
|
||||||
|
logging.UnaryServerInterceptor(interceptorLogger(log.Logger), logOpts...),
|
||||||
|
recovery.UnaryServerInterceptor(recoveryOpts...),
|
||||||
|
),
|
||||||
|
grpc.ChainStreamInterceptor(
|
||||||
|
srvMetrics.StreamServerInterceptor(),
|
||||||
|
logging.StreamServerInterceptor(interceptorLogger(log.Logger), logOpts...),
|
||||||
|
recovery.StreamServerInterceptor(recoveryOpts...),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := setupDatabase(ctx, config)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
torrentClient, err := torrent.NewTorrentClient(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create torrent client")
|
||||||
|
}
|
||||||
|
|
||||||
|
pathMapper, err := torrent.NewPathMapper(config.Torrent.ContainerName, torrentClient)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create path mapper")
|
||||||
|
}
|
||||||
|
|
||||||
|
rs := setupRiver(ctx, config, db, torrentClient, pathMapper)
|
||||||
|
|
||||||
|
musiscAgregatorSeerver, err := internal.NewMusicAgregatorServer(config, rs.client, torrentClient, pathMapper, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create MusicAgregatorServer")
|
||||||
|
}
|
||||||
|
indexerServer, err := indexer.NewIndexerServer(config, rs.client, rs.cacheRefreshWorker)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create IndexerServer")
|
||||||
|
}
|
||||||
|
torrentServer := torrent.NewTorrentServer(torrentClient)
|
||||||
|
metadataServer, err := metadata.NewMetadataServer(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create MetadataServer")
|
||||||
|
}
|
||||||
|
|
||||||
|
services := []internal.Registrable{
|
||||||
|
hello.NewHelloServer(),
|
||||||
|
indexerServer,
|
||||||
|
torrentServer,
|
||||||
|
metadataServer,
|
||||||
|
musiscAgregatorSeerver,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, service := range services {
|
||||||
|
service.Register(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
srvMetrics.InitializeMetrics(server)
|
||||||
|
prometheus.MustRegister(srvMetrics)
|
||||||
|
reflection.Register(server)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
log.Info().Msg("Prometheus metrics available at :9090/metrics")
|
||||||
|
if err := http.ListenAndServe(":9090", mux); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to start metrics server")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("%v:%v", config.App.Host, config.App.Port))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to listen")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Str("addr", listener.Addr().String()).Msg("gRPC server listening")
|
||||||
|
server.Serve(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfig(path *string) (*config.Config, error) {
|
||||||
|
f, err := os.Open(*path)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("path", *path).Msg("Unable to opent config by path")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
cfg := config.NewConfig()
|
||||||
|
decoder := yaml.NewDecoder(f)
|
||||||
|
err = decoder.Decode(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
// processError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ database:
|
|||||||
metadata:
|
metadata:
|
||||||
endpoint: "http://localhost:50051"
|
endpoint: "http://localhost:50051"
|
||||||
|
|
||||||
indexers:
|
indexer:
|
||||||
- name: "Jackett"
|
name: "Jackett"
|
||||||
indexer_type: jackett # jackett, prowlarr, or torznab
|
indexer_type: jackett # jackett, prowlarr, or torznab
|
||||||
url: "http://localhost:9117"
|
url: "http://localhost:9117"
|
||||||
api_key: "your-jackett-api-key"
|
api_key: "your-jackett-api-key"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
CREATE TYPE monitor_state AS ENUM ('unmonitored', 'monitored', 'excluded');
|
||||||
|
CREATE TYPE download_state AS ENUM ('pending', 'downloading', 'completed', 'failed', 'seeding');
|
||||||
|
|
||||||
|
CREATE TABLE artists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
artist_type VARCHAR(50) NOT NULL,
|
||||||
|
country VARCHAR(10),
|
||||||
|
genres TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
image_url TEXT,
|
||||||
|
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_artists_monitor_state ON artists(monitor_state);
|
||||||
|
|
||||||
|
CREATE TABLE albums (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
album_type VARCHAR(50) NOT NULL,
|
||||||
|
release_date DATE,
|
||||||
|
total_tracks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_discs INTEGER NOT NULL DEFAULT 1,
|
||||||
|
label VARCHAR(255),
|
||||||
|
genres TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
cover_url TEXT,
|
||||||
|
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_albums_artist_id ON albums(artist_id);
|
||||||
|
CREATE INDEX idx_albums_monitor_state ON albums(monitor_state);
|
||||||
|
|
||||||
|
CREATE TABLE tracks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
isrc VARCHAR(20),
|
||||||
|
disc_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
track_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tracks_album_id ON tracks(album_id);
|
||||||
|
|
||||||
|
CREATE TABLE torrents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
info_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
tracker VARCHAR(100) NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
format VARCHAR(20) NOT NULL,
|
||||||
|
quality VARCHAR(20) NOT NULL,
|
||||||
|
source VARCHAR(50),
|
||||||
|
bit_depth INTEGER,
|
||||||
|
sample_rate INTEGER,
|
||||||
|
seeders INTEGER NOT NULL DEFAULT 0,
|
||||||
|
peers INTEGER NOT NULL DEFAULT 0,
|
||||||
|
size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
track_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_cover_art BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_cue_sheet BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_rip_log BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
download_link TEXT,
|
||||||
|
torrent_file BYTEA,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_torrents_album_id ON torrents(album_id);
|
||||||
|
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
torrent_id UUID NOT NULL REFERENCES torrents(id) ON DELETE CASCADE,
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
format VARCHAR(20) NOT NULL,
|
||||||
|
quality VARCHAR(20) NOT NULL,
|
||||||
|
state download_state NOT NULL DEFAULT 'pending',
|
||||||
|
qbit_hash VARCHAR(64),
|
||||||
|
save_path TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_downloads_album_id ON downloads(album_id);
|
||||||
|
CREATE INDEX idx_downloads_torrent_id ON downloads(torrent_id);
|
||||||
|
CREATE INDEX idx_downloads_state ON downloads(state);
|
||||||
|
|
||||||
|
CREATE TABLE download_files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
download_id UUID NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||||
|
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
file_type VARCHAR(50) NOT NULL,
|
||||||
|
sha256_hash VARCHAR(64),
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_download_files_download_id ON download_files(download_id);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE workflow_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
quality VARCHAR(20) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'running',
|
||||||
|
error_message TEXT,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
running_lock BOOLEAN GENERATED ALWAYS AS (CASE WHEN status = 'running' THEN TRUE ELSE NULL END) STORED,
|
||||||
|
CONSTRAINT idx_workflow_runs_active UNIQUE (album_id, quality, running_lock)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_workflow_runs_status ON workflow_runs(status);
|
||||||
|
|
||||||
|
CREATE TABLE album_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
seq BIGSERIAL NOT NULL,
|
||||||
|
workflow_run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
||||||
|
album_id UUID NOT NULL,
|
||||||
|
event_type VARCHAR(20) NOT NULL,
|
||||||
|
step VARCHAR(50) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
data_json JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_album_events_workflow ON album_events(workflow_run_id);
|
||||||
|
CREATE INDEX idx_album_events_album ON album_events(album_id);
|
||||||
|
CREATE INDEX idx_album_events_seq ON album_events(seq);
|
||||||
|
|
||||||
|
ALTER TYPE download_state ADD VALUE IF NOT EXISTS 'cancelled';
|
||||||
@@ -1,23 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: music-aggregator-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: music
|
|
||||||
POSTGRES_PASSWORD: music
|
|
||||||
POSTGRES_DB: music_aggregator
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U music -d music_aggregator"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
jackett:
|
jackett:
|
||||||
image: lscr.io/linuxserver/jackett:latest
|
image: lscr.io/linuxserver/jackett:latest
|
||||||
container_name: music-aggregator-jackett
|
container_name: music-aggregator-jackett
|
||||||
@@ -52,11 +33,11 @@ services:
|
|||||||
- "6881:6881"
|
- "6881:6881"
|
||||||
- "6881:6881/udp"
|
- "6881:6881/udp"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080"]
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:9999"]
|
||||||
interval: 30s
|
interval: 10s
|
||||||
timeout: 10s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 15s
|
||||||
|
|
||||||
qbittorrent:
|
qbittorrent:
|
||||||
image: lscr.io/linuxserver/qbittorrent:latest
|
image: lscr.io/linuxserver/qbittorrent:latest
|
||||||
@@ -76,7 +57,6 @@ services:
|
|||||||
- downloads:/downloads
|
- downloads:/downloads
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
|
||||||
jackett_config:
|
jackett_config:
|
||||||
jackett_downloads:
|
jackett_downloads:
|
||||||
qbittorrent_config:
|
qbittorrent_config:
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
-- Music Aggregator Database Schema
|
|
||||||
-- Based on docs/erd.puml
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- CONFIGURATION
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TABLE quality_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
cutoff INT NOT NULL DEFAULT 0,
|
|
||||||
items JSONB NOT NULL DEFAULT '[]',
|
|
||||||
upgrade_allowed BOOLEAN NOT NULL DEFAULT true
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE metadata_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
primary_album_types JSONB NOT NULL DEFAULT '["Album", "EP"]',
|
|
||||||
secondary_album_types JSONB NOT NULL DEFAULT '[]',
|
|
||||||
release_statuses JSONB NOT NULL DEFAULT '["Official"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE root_folders (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL UNIQUE,
|
|
||||||
default_quality_profile_id UUID REFERENCES quality_profiles(id),
|
|
||||||
default_metadata_profile_id UUID REFERENCES metadata_profiles(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE indexers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
implementation TEXT NOT NULL,
|
|
||||||
settings JSONB NOT NULL DEFAULT '{}',
|
|
||||||
enable_rss BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
enable_search BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
priority INT NOT NULL DEFAULT 25
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE download_clients (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
implementation TEXT NOT NULL,
|
|
||||||
settings JSONB NOT NULL DEFAULT '{}',
|
|
||||||
protocol TEXT NOT NULL DEFAULT 'torrent',
|
|
||||||
priority INT NOT NULL DEFAULT 1,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- CORE MUSIC ENTITIES
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TABLE artist_metadata (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
foreign_artist_id TEXT UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sort_name TEXT,
|
|
||||||
disambiguation TEXT,
|
|
||||||
artist_type TEXT,
|
|
||||||
status TEXT,
|
|
||||||
overview TEXT,
|
|
||||||
images JSONB NOT NULL DEFAULT '[]',
|
|
||||||
links JSONB NOT NULL DEFAULT '[]',
|
|
||||||
genres JSONB NOT NULL DEFAULT '[]',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE artists (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
|
|
||||||
quality_profile_id UUID REFERENCES quality_profiles(id),
|
|
||||||
metadata_profile_id UUID REFERENCES metadata_profiles(id),
|
|
||||||
root_folder_id UUID REFERENCES root_folders(id),
|
|
||||||
path TEXT,
|
|
||||||
monitored BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
monitor_new_items TEXT NOT NULL DEFAULT 'all',
|
|
||||||
last_info_sync TIMESTAMPTZ,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE albums (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
|
|
||||||
foreign_album_id TEXT UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
clean_title TEXT,
|
|
||||||
disambiguation TEXT,
|
|
||||||
overview TEXT,
|
|
||||||
album_type TEXT,
|
|
||||||
release_date DATE,
|
|
||||||
images JSONB NOT NULL DEFAULT '[]',
|
|
||||||
genres JSONB NOT NULL DEFAULT '[]',
|
|
||||||
monitored BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE album_releases (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
foreign_release_id TEXT UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
status TEXT,
|
|
||||||
duration_ms INT,
|
|
||||||
release_date DATE,
|
|
||||||
country TEXT[],
|
|
||||||
label TEXT[],
|
|
||||||
format TEXT,
|
|
||||||
track_count INT,
|
|
||||||
monitored BOOLEAN NOT NULL DEFAULT true
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE track_files (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
relative_path TEXT NOT NULL,
|
|
||||||
size BIGINT NOT NULL DEFAULT 0,
|
|
||||||
file_hash TEXT,
|
|
||||||
audio_hash TEXT,
|
|
||||||
quality JSONB NOT NULL DEFAULT '{}',
|
|
||||||
media_info JSONB NOT NULL DEFAULT '{}',
|
|
||||||
scene_name TEXT,
|
|
||||||
release_group TEXT,
|
|
||||||
date_added TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tracks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_release_id UUID NOT NULL REFERENCES album_releases(id) ON DELETE CASCADE,
|
|
||||||
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
|
|
||||||
track_file_id UUID REFERENCES track_files(id) ON DELETE SET NULL,
|
|
||||||
foreign_track_id TEXT UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
track_number INT NOT NULL DEFAULT 1,
|
|
||||||
disc_number INT NOT NULL DEFAULT 1,
|
|
||||||
duration_ms INT,
|
|
||||||
explicit BOOLEAN NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- DOWNLOAD TRACKING
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TABLE wanted_albums (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
album_id UUID NOT NULL UNIQUE REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
priority INT NOT NULL DEFAULT 0,
|
|
||||||
search_count INT NOT NULL DEFAULT 0,
|
|
||||||
last_searched_at TIMESTAMPTZ,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE download_queue (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
artist_id UUID REFERENCES artists(id) ON DELETE SET NULL,
|
|
||||||
album_id UUID REFERENCES albums(id) ON DELETE SET NULL,
|
|
||||||
download_id TEXT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
size BIGINT NOT NULL DEFAULT 0,
|
|
||||||
size_left BIGINT NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
progress REAL NOT NULL DEFAULT 0.0,
|
|
||||||
error_message TEXT,
|
|
||||||
protocol TEXT NOT NULL DEFAULT 'torrent',
|
|
||||||
indexer TEXT,
|
|
||||||
download_client TEXT,
|
|
||||||
torrent_hash TEXT,
|
|
||||||
output_path TEXT,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
completed_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE blocklist (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
|
||||||
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
|
|
||||||
source_title TEXT NOT NULL,
|
|
||||||
quality JSONB NOT NULL DEFAULT '{}',
|
|
||||||
size BIGINT NOT NULL DEFAULT 0,
|
|
||||||
protocol TEXT,
|
|
||||||
indexer TEXT,
|
|
||||||
message TEXT,
|
|
||||||
torrent_hash TEXT,
|
|
||||||
date TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- INDEXES
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE INDEX idx_artist_metadata_name ON artist_metadata(name);
|
|
||||||
CREATE INDEX idx_artist_metadata_foreign_id ON artist_metadata(foreign_artist_id);
|
|
||||||
CREATE INDEX idx_albums_artist ON albums(artist_metadata_id);
|
|
||||||
CREATE INDEX idx_albums_foreign_id ON albums(foreign_album_id);
|
|
||||||
CREATE INDEX idx_albums_release_date ON albums(release_date);
|
|
||||||
CREATE INDEX idx_album_releases_album ON album_releases(album_id);
|
|
||||||
CREATE INDEX idx_tracks_release ON tracks(album_release_id);
|
|
||||||
CREATE INDEX idx_tracks_artist ON tracks(artist_metadata_id);
|
|
||||||
CREATE INDEX idx_track_files_album ON track_files(album_id);
|
|
||||||
CREATE INDEX idx_track_files_hash ON track_files(file_hash);
|
|
||||||
CREATE INDEX idx_track_files_audio_hash ON track_files(audio_hash);
|
|
||||||
CREATE INDEX idx_wanted_albums_priority ON wanted_albums(priority DESC);
|
|
||||||
CREATE INDEX idx_download_queue_status ON download_queue(status);
|
|
||||||
CREATE INDEX idx_download_queue_album ON download_queue(album_id);
|
|
||||||
CREATE INDEX idx_blocklist_artist ON blocklist(artist_id);
|
|
||||||
CREATE INDEX idx_blocklist_torrent ON blocklist(torrent_hash);
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
-- DEFAULT DATA
|
|
||||||
-- ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
INSERT INTO quality_profiles (name, cutoff, items, upgrade_allowed) VALUES
|
|
||||||
('Any', 0, '[]', true),
|
|
||||||
('Lossless', 1, '[{"quality": "FLAC", "allowed": true}, {"quality": "ALAC", "allowed": true}]', true),
|
|
||||||
('Standard', 2, '[{"quality": "MP3-320", "allowed": true}, {"quality": "MP3-VBR-V0", "allowed": true}]', true);
|
|
||||||
|
|
||||||
INSERT INTO metadata_profiles (name, primary_album_types, secondary_album_types, release_statuses) VALUES
|
|
||||||
('Standard', '["Album", "EP"]', '[]', '["Official"]'),
|
|
||||||
('All', '["Album", "EP", "Single", "Broadcast", "Other"]', '["Compilation", "Soundtrack", "Spokenword", "Interview", "Audiobook", "Live", "Remix", "DJ-mix", "Mixtape/Street", "Demo"]', '["Official", "Promotional", "Bootleg"]');
|
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
@startuml MonitorAlbum - Already Owned (Early Return)
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbum: Album Already Owned in Requested Quality
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "MusicAgregatorService" as Service
|
||||||
|
participant "MetadataService" as Metadata
|
||||||
|
database "metadata-agregator\n(gRPC)" as MetaGRPC
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS)
|
||||||
|
Service -> Metadata: GetAlbum(album_id)
|
||||||
|
Metadata -> MetaGRPC: GetAlbum(id)
|
||||||
|
MetaGRPC --> Metadata: Album
|
||||||
|
Metadata -> DB: albums.GetByExternalID()
|
||||||
|
DB --> Metadata: found (already persisted)
|
||||||
|
note right: Album exists from\nprevious MonitorAlbum\nor GetArtists discovery
|
||||||
|
Metadata --> Service: Album
|
||||||
|
|
||||||
|
Service -> DB: albums.GetByExternalID()
|
||||||
|
DB --> Service: dbAlbum
|
||||||
|
Service -> DB: albums.SetMonitorState(id, monitored)
|
||||||
|
note right #lightgreen: Always set monitored\nregardless of outcome
|
||||||
|
|
||||||
|
Service -> DB: downloads.HasAlbumInQuality(id, LOSSLESS, "16-44")
|
||||||
|
DB --> Service: true
|
||||||
|
note right #lightgreen: Found existing download\nwith state IN\n('completed', 'seeding')
|
||||||
|
|
||||||
|
Service -> Service: buildMonitorAlbumResponse()
|
||||||
|
Service -> DB: downloads.GetByAlbumID()
|
||||||
|
DB --> Service: download (completed, FLAC, /downloads)
|
||||||
|
Service -> DB: artists.GetByExternalID()
|
||||||
|
DB --> Service: artist
|
||||||
|
|
||||||
|
Service --> Client: MonitorAlbumResponse
|
||||||
|
note right #lightgreen: album: monitored, download info\nartist: monitored\nrelease: nil (no new search)\n\nNo indexer search.\nNo torrent client interaction.\nFast response.
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
@startuml MonitorAlbum - Indexer & Parse Failures
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbum Error: Indexer Search & Parse Failures
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "MusicAgregatorService" as Service
|
||||||
|
participant "IndexerService\n(Jackett)" as Indexer
|
||||||
|
participant "MagnetResolver" as Magnet
|
||||||
|
|
||||||
|
== Case 1: Indexer search fails (Jackett down / timeout) ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality)
|
||||||
|
note right: metadata fetch + persist succeeded
|
||||||
|
|
||||||
|
Service -> Indexer: Search("Artist Album", tracker)
|
||||||
|
Indexer --> Service: error (connection refused / timeout)
|
||||||
|
Service --> Client: error
|
||||||
|
note right #salmon: Full stop after metadata.\nAlbum is persisted & monitored\nbut no torrent found.
|
||||||
|
|
||||||
|
== Case 2: Indexer returns zero results ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality)
|
||||||
|
Service -> Indexer: Search("Artist Album", tracker)
|
||||||
|
Indexer --> Service: SearchResponse (0 items)
|
||||||
|
Service -> Service: parseSearchResults → empty
|
||||||
|
Service -> Service: filterByQuality → empty
|
||||||
|
Service --> Client: MonitorAlbumResponse
|
||||||
|
note right #orange: Partial response.\nalbum + artist returned.\nrelease: nil.\nNo torrent added.
|
||||||
|
|
||||||
|
== Case 3: All results have no seeders ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality)
|
||||||
|
Service -> Indexer: Search(...)
|
||||||
|
Indexer --> Service: SearchResponse (5 items, all seeders=0)
|
||||||
|
|
||||||
|
loop for each item
|
||||||
|
Service -> Service: item.Seeders == 0 → skip
|
||||||
|
note right: Logged as warning per item
|
||||||
|
end
|
||||||
|
|
||||||
|
Service -> Service: parsed = empty
|
||||||
|
Service -> Service: filterByQuality → empty
|
||||||
|
Service --> Client: MonitorAlbumResponse (no release)
|
||||||
|
|
||||||
|
== Case 4: All magnet resolves fail ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality)
|
||||||
|
Service -> Indexer: Search(...)
|
||||||
|
Indexer --> Service: SearchResponse (3 items)
|
||||||
|
|
||||||
|
loop for each item
|
||||||
|
Service -> Magnet: Resolve(magnet_uri)
|
||||||
|
Magnet --> Service: error (timeout / no active peers)
|
||||||
|
Service -> Service: fallback to title parse
|
||||||
|
note right: Release parsed from title only.\nFormat may be "unknown".\nNo torrent data (nil).
|
||||||
|
end
|
||||||
|
|
||||||
|
Service -> Service: filterByQuality(parsed, LOSSLESS)
|
||||||
|
note right #orange: Title-parsed releases may have\nformat=unknown → not lossless\n→ filtered out
|
||||||
|
Service --> Client: MonitorAlbumResponse (no release)
|
||||||
|
|
||||||
|
== Case 5: No results match quality filter ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS)
|
||||||
|
Service -> Indexer: Search(...)
|
||||||
|
Indexer --> Service: SearchResponse (3 items)
|
||||||
|
Service -> Service: parseSearchResults → 3 items (all MP3)
|
||||||
|
Service -> Service: filterByQuality(LOSSLESS) → empty
|
||||||
|
note right: All releases are lossy.\nQuality filter rejects all.
|
||||||
|
Service --> Client: MonitorAlbumResponse
|
||||||
|
note right #orange: album + artist returned.\nrelease: nil.\n"no releases match quality filter"
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
@startuml MonitorAlbum - Metadata Fetch Failures
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbum Error: Metadata Fetch Failures
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "MusicAgregatorService" as Service
|
||||||
|
participant "MetadataService" as Metadata
|
||||||
|
database "metadata-agregator\n(gRPC)" as MetaGRPC
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
|
||||||
|
== Case 1: metadata-agregator unreachable ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality)
|
||||||
|
Service -> Metadata: GetAlbum(album_id)
|
||||||
|
Metadata -> MetaGRPC: GetAlbum(id)
|
||||||
|
MetaGRPC --> Metadata: gRPC error (Unavailable)
|
||||||
|
Metadata --> Service: error: "fetching album: ..."
|
||||||
|
Service --> Client: error (Unavailable)
|
||||||
|
note right: Full stop.\nNo DB writes occur.\nNo indexer search.
|
||||||
|
|
||||||
|
== Case 2: Album not found in metadata ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality)
|
||||||
|
Service -> Metadata: GetAlbum(album_id)
|
||||||
|
Metadata -> MetaGRPC: GetAlbum(id)
|
||||||
|
MetaGRPC --> Metadata: gRPC error (NotFound)
|
||||||
|
Metadata --> Service: error: "fetching album: ..."
|
||||||
|
Service --> Client: error (NotFound)
|
||||||
|
note right: Full stop.\nInvalid album_id.\nNo side effects.
|
||||||
|
|
||||||
|
== Case 3: Album found, but artist persist fails ==
|
||||||
|
|
||||||
|
Client -> Service: MonitorAlbum(album_id, quality)
|
||||||
|
Service -> Metadata: GetAlbum(album_id)
|
||||||
|
Metadata -> MetaGRPC: GetAlbum(id)
|
||||||
|
MetaGRPC --> Metadata: Album
|
||||||
|
|
||||||
|
Metadata -> DB: albums.GetByExternalID()
|
||||||
|
DB --> Metadata: not found
|
||||||
|
|
||||||
|
Metadata -> DB: artists.Create()
|
||||||
|
DB --> Metadata: error (e.g. connection lost)
|
||||||
|
note right #salmon: Artist persist fails.\nLogged as warning.\nFlow continues.
|
||||||
|
|
||||||
|
Metadata -> DB: albums.Create()
|
||||||
|
note right #salmon: artistID is empty\n→ album persist skipped\n(no artist reference)
|
||||||
|
|
||||||
|
Metadata --> Service: Album (metadata only)
|
||||||
|
|
||||||
|
Service -> DB: albums.GetByExternalID()
|
||||||
|
DB --> Service: not found (album never persisted)
|
||||||
|
note right #salmon: dbAlbum is nil.\nMonitor state not set.\nOwnership check skipped.
|
||||||
|
|
||||||
|
Service -> Service: continues to indexer search...
|
||||||
|
note right: Flow proceeds but\ndownload persistence\nwill be skipped later\n(dbAlbum == nil)
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
@startuml MonitorAlbum - Async Poll Worker Failures
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbum: Async Download Poll Worker Failures
|
||||||
|
|
||||||
|
participant "River Queue" as River
|
||||||
|
participant "PollDownloadWorker" as Worker
|
||||||
|
participant "TorrentClient\n(qBittorrent)" as QBit
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
|
||||||
|
note over Worker: These occur asynchronously\nafter MonitorAlbum returns.\nClient already received response.
|
||||||
|
|
||||||
|
== Case 1: qBittorrent unreachable during poll ==
|
||||||
|
|
||||||
|
River -> Worker: Work(PollDownloadArgs)
|
||||||
|
Worker -> QBit: Find(hash)
|
||||||
|
QBit --> Worker: error (connection refused)
|
||||||
|
note right #orange: Logged as error.\nJob rescheduled.
|
||||||
|
|
||||||
|
Worker -> River: Insert(PollDownloadArgs)
|
||||||
|
note right: Reschedule after check_interval (30s).\nRetries indefinitely until\nqBit becomes available.
|
||||||
|
|
||||||
|
== Case 2: Torrent disappeared from qBittorrent ==
|
||||||
|
|
||||||
|
River -> Worker: Work(PollDownloadArgs)
|
||||||
|
Worker -> QBit: Find(hash)
|
||||||
|
QBit --> Worker: empty results
|
||||||
|
note right #salmon: Torrent was removed\nfrom qBit externally.
|
||||||
|
|
||||||
|
Worker -> DB: downloads.SetFailed(id, "torrent not found in client")
|
||||||
|
note right: Download marked as failed.\nNo further polls scheduled.\nNo retry.
|
||||||
|
|
||||||
|
== Case 3: Torrent in error state ==
|
||||||
|
|
||||||
|
River -> Worker: Work(PollDownloadArgs)
|
||||||
|
Worker -> QBit: Find(hash)
|
||||||
|
QBit --> Worker: TorrentInfo{state: "error"}
|
||||||
|
note right #salmon: qBit reports torrent error.\n(e.g. tracker unreachable,\ncorrupt data, disk full)
|
||||||
|
|
||||||
|
Worker -> DB: downloads.SetFailed(id, "torrent error state")
|
||||||
|
note right: Download marked as failed.\nNo further polls.\nTorrent remains in qBit.
|
||||||
|
|
||||||
|
== Case 4: Download completes, but SetCompleted fails ==
|
||||||
|
|
||||||
|
River -> Worker: Work(PollDownloadArgs)
|
||||||
|
Worker -> QBit: Find(hash)
|
||||||
|
QBit --> Worker: TorrentInfo{progress: 1.0, savePath: "/downloads"}
|
||||||
|
|
||||||
|
Worker -> DB: downloads.SetCompleted(id, "/downloads")
|
||||||
|
DB --> Worker: error (DB connection lost)
|
||||||
|
note right #salmon: Worker returns error.\nRiver will retry the job\n(built-in retry policy).\nDownload stays in\n"downloading" state.
|
||||||
|
|
||||||
|
== Case 5: File scan fails after completion ==
|
||||||
|
|
||||||
|
River -> Worker: Work(PollDownloadArgs)
|
||||||
|
Worker -> QBit: Find(hash)
|
||||||
|
QBit --> Worker: TorrentInfo{progress: 1.0, path: "/downloads/Album"}
|
||||||
|
|
||||||
|
Worker -> DB: downloads.SetCompleted(id, "/downloads")
|
||||||
|
DB --> Worker: OK
|
||||||
|
note right #lightgreen: Download marked completed.
|
||||||
|
|
||||||
|
Worker -> Worker: scanAndHashFiles("/downloads/Album")
|
||||||
|
Worker --> Worker: error (permission denied / path not found)
|
||||||
|
note right #orange: Logged as error.\nDownload IS completed.\nBut download_files NOT populated.\nGetAlbum won't show file info\nfor individual tracks.
|
||||||
|
|
||||||
|
== Case 6: File persist fails ==
|
||||||
|
|
||||||
|
River -> Worker: Work(PollDownloadArgs)
|
||||||
|
Worker -> QBit: Find(hash)
|
||||||
|
QBit --> Worker: TorrentInfo{progress: 1.0}
|
||||||
|
|
||||||
|
Worker -> DB: downloads.SetCompleted(id, savePath)
|
||||||
|
Worker -> Worker: scanAndHashFiles → 12 files
|
||||||
|
Worker -> DB: download_files.CreateBatch(files)
|
||||||
|
DB --> Worker: error (duplicate / constraint)
|
||||||
|
note right #orange: Download is completed.\nFiles not persisted.\nNon-fatal: returns nil.\nNo retry.
|
||||||
|
|
||||||
|
== Case 7: App crash during download (startup recovery) ==
|
||||||
|
|
||||||
|
note over River, Worker: Application restarts.\nRiver picks up persisted jobs.
|
||||||
|
|
||||||
|
River -> Worker: RecoverOrphanedDownloads()
|
||||||
|
Worker -> DB: downloads.GetActive()
|
||||||
|
DB --> Worker: [Download{state: downloading, hash: ...}]
|
||||||
|
|
||||||
|
Worker -> River: Insert(PollDownloadArgs, UniqueOpts{ByArgs})
|
||||||
|
note right #lightgreen: Deduplicated insert.\nIf River job already exists\n→ no duplicate.\nIf job was lost → recovered.\nPolling resumes.
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
@startuml MonitorAlbum - Torrent Client Failures
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbum Error: Torrent Client Failures
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "MusicAgregatorService" as Service
|
||||||
|
participant "TorrentClient\n(qBittorrent)" as QBit
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
|
||||||
|
note over Service: Metadata fetched, indexer searched,\nbest release selected.
|
||||||
|
|
||||||
|
== Case 1: qBittorrent unreachable ==
|
||||||
|
|
||||||
|
Service -> QBit: Find(hash)
|
||||||
|
QBit --> Service: error (connection refused)
|
||||||
|
note right: Find() fails → skip existence check
|
||||||
|
|
||||||
|
Service -> QBit: AddMagnet(uri)
|
||||||
|
QBit --> Service: error (connection refused)
|
||||||
|
Service --> Client: error
|
||||||
|
note right #salmon: Album is persisted & monitored.\nNo torrent added.\nNo download record created.
|
||||||
|
|
||||||
|
== Case 2: Torrent already exists in qBit ==
|
||||||
|
|
||||||
|
Service -> QBit: Find(hash)
|
||||||
|
QBit --> Service: [TorrentInfo{state: stalledUP}]
|
||||||
|
note right #lightgreen: Torrent found.\nSkip AddMagnet/AddTorrent.
|
||||||
|
|
||||||
|
Service -> DB: torrents.Create (upsert)
|
||||||
|
Service -> DB: torrents.GetByInfoHash
|
||||||
|
DB --> Service: savedTorrent
|
||||||
|
|
||||||
|
Service -> DB: downloads.GetActiveByTorrentID(torrent_id)
|
||||||
|
DB --> Service: Download{state: completed}
|
||||||
|
note right #lightgreen: Active download exists.\nSkip duplicate insert.
|
||||||
|
|
||||||
|
Service --> Client: MonitorAlbumResponse
|
||||||
|
note right: Returns album + artist + release.\nNo duplicate download created.\nNo error.
|
||||||
|
|
||||||
|
== Case 3: AddTorrent fails (no torrent data) ==
|
||||||
|
|
||||||
|
Service -> QBit: Find(hash)
|
||||||
|
QBit --> Service: not found
|
||||||
|
|
||||||
|
note right: best.torrentData is nil\n(magnet resolve failed,\nfell back to title parse)
|
||||||
|
|
||||||
|
Service -> Service: len(best.torrentData) == 0
|
||||||
|
Service --> Client: error "no torrent data available"
|
||||||
|
note right #salmon: Magnet link but no\ntorrent data resolved.\nCannot add to client.
|
||||||
|
|
||||||
|
== Case 4: Torrent persists, but download insert fails ==
|
||||||
|
|
||||||
|
Service -> QBit: AddMagnet(uri)
|
||||||
|
QBit --> Service: OK
|
||||||
|
|
||||||
|
Service -> DB: torrents.Create (upsert)
|
||||||
|
Service -> DB: torrents.GetByInfoHash
|
||||||
|
DB --> Service: savedTorrent
|
||||||
|
|
||||||
|
Service -> DB: downloads.GetActiveByTorrentID
|
||||||
|
DB --> Service: not found
|
||||||
|
|
||||||
|
Service -> DB: downloads.Create(download)
|
||||||
|
DB --> Service: error (constraint violation)
|
||||||
|
note right #salmon: Logged as error.\nTorrent is in qBit but\nno download record.\nResponse still returned\n(non-fatal within saveTorrentAndDownload).\nNo poll job scheduled.
|
||||||
|
|
||||||
|
Service --> Client: MonitorAlbumResponse
|
||||||
|
note right #orange: Response includes release info.\nDownload info may be missing.\nTorrent is downloading in qBit\nbut untracked in DB.
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
After Width: | Height: | Size: 163 KiB |
@@ -0,0 +1,138 @@
|
|||||||
|
@startuml MonitorAlbum Happy Path
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "gRPC Server" as Server
|
||||||
|
participant "MusicAgregatorService" as Service
|
||||||
|
participant "MetadataService" as Metadata
|
||||||
|
database "metadata-agregator\n(gRPC)" as MetaGRPC
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
participant "IndexerService\n(Jackett)" as Indexer
|
||||||
|
participant "MagnetResolver" as Magnet
|
||||||
|
participant "TorrentClient\n(qBittorrent)" as QBit
|
||||||
|
participant "River Queue" as River
|
||||||
|
participant "PollDownloadWorker" as PollWorker
|
||||||
|
|
||||||
|
== 1. Fetch Album Metadata ==
|
||||||
|
|
||||||
|
Client -> Server: MonitorAlbum(album_id, quality, tracker)
|
||||||
|
Server -> Service: MonitorAlbum(ctx, req)
|
||||||
|
|
||||||
|
Service -> Metadata: GetAlbum(album_id)
|
||||||
|
Metadata -> MetaGRPC: GetAlbum(id)
|
||||||
|
MetaGRPC --> Metadata: Album (title, artists, genres, ...)
|
||||||
|
|
||||||
|
Metadata -> DB: albums.GetByExternalID(external_id)
|
||||||
|
note right: Check if album already persisted
|
||||||
|
DB --> Metadata: not found
|
||||||
|
|
||||||
|
Metadata -> DB: artists.Create(artist, state=monitored)
|
||||||
|
note right: Upsert artist\nnever downgrades\nmonitored/excluded
|
||||||
|
Metadata -> DB: albums.Create(album, state=monitored)
|
||||||
|
note right: Upsert album\nnever downgrades\nmonitored/excluded
|
||||||
|
Metadata --> Service: Album
|
||||||
|
|
||||||
|
== 2. Set Monitor State ==
|
||||||
|
|
||||||
|
Service -> DB: albums.GetByExternalID(external_id)
|
||||||
|
DB --> Service: dbAlbum
|
||||||
|
Service -> DB: albums.SetMonitorState(id, monitored)
|
||||||
|
note right: Explicitly mark\nalbum as monitored
|
||||||
|
|
||||||
|
== 3. Check If Already Owned ==
|
||||||
|
|
||||||
|
Service -> DB: downloads.HasAlbumInQuality(album_id, format, quality)
|
||||||
|
DB --> Service: false (not owned)
|
||||||
|
|
||||||
|
== 4. Search Indexers ==
|
||||||
|
|
||||||
|
Service -> Indexer: Search(artist + album title, tracker)
|
||||||
|
Indexer -> Indexer: Jackett API\n/api/v2.0/indexers/all/results
|
||||||
|
Indexer --> Service: SearchResponse (N items)
|
||||||
|
|
||||||
|
== 5. Parse & Resolve Releases ==
|
||||||
|
|
||||||
|
loop for each search result (with download link & seeders > 0)
|
||||||
|
alt magnet link
|
||||||
|
Service -> Magnet: Resolve(magnet_uri)
|
||||||
|
note right: DHT lookup, 30s timeout\n15s early exit if peers\nbut none active
|
||||||
|
Magnet --> Service: torrent metadata (files, hash, size)
|
||||||
|
Service -> Service: ParseTorrent(torrentData, album)
|
||||||
|
else HTTP torrent link
|
||||||
|
Service -> Service: downloadTorrentData(url)
|
||||||
|
Service -> Service: ParseTorrent(torrentData, album)
|
||||||
|
end
|
||||||
|
note right: Extract: format, bitDepth, sampleRate,\nsource, trackCount, coverArt, cueSheet, ripLog
|
||||||
|
end
|
||||||
|
|
||||||
|
== 6. Filter & Select Best ==
|
||||||
|
|
||||||
|
Service -> Service: filterByQuality(parsed, quality)
|
||||||
|
note right: Match LOSSLESS/LOSSY/UNSPECIFIED\nagainst release format
|
||||||
|
Service -> Service: selectBestRelease(filtered)
|
||||||
|
note right: Highest seeder count wins
|
||||||
|
|
||||||
|
== 7. Add to Torrent Client ==
|
||||||
|
|
||||||
|
Service -> QBit: Find(hash)
|
||||||
|
QBit --> Service: not found
|
||||||
|
|
||||||
|
alt magnet link
|
||||||
|
Service -> QBit: AddMagnet(magnet_uri)
|
||||||
|
else torrent file
|
||||||
|
Service -> QBit: AddTorrent(file)
|
||||||
|
end
|
||||||
|
QBit --> Service: OK
|
||||||
|
|
||||||
|
== 8. Persist Torrent & Download ==
|
||||||
|
|
||||||
|
Service -> DB: torrents.Create(torrent)
|
||||||
|
note right: Upsert on info_hash\nupdates seeders/peers
|
||||||
|
Service -> DB: torrents.GetByInfoHash(hash)
|
||||||
|
DB --> Service: savedTorrent (with DB id)
|
||||||
|
|
||||||
|
Service -> DB: downloads.GetActiveByTorrentID(torrent_id)
|
||||||
|
DB --> Service: not found (no active download)
|
||||||
|
|
||||||
|
Service -> DB: downloads.Create(download)
|
||||||
|
note right: state = "downloading"\nformat, quality, qbit_hash
|
||||||
|
DB --> Service: download (with DB id)
|
||||||
|
|
||||||
|
== 9. Schedule Download Poll ==
|
||||||
|
|
||||||
|
Service -> River: Insert(PollDownloadArgs)
|
||||||
|
note right: download_id, torrent_hash\ncheck_interval = 30s\nscheduled_at = now + 30s
|
||||||
|
River --> Service: job scheduled
|
||||||
|
|
||||||
|
== 10. Build & Return Response ==
|
||||||
|
|
||||||
|
Service -> DB: albums.GetByExternalID(external_id)
|
||||||
|
DB --> Service: dbAlbum (refreshed)
|
||||||
|
Service -> DB: downloads.GetByAlbumID(album_id)
|
||||||
|
DB --> Service: downloads (with state)
|
||||||
|
Service -> DB: artists.GetByExternalID(artist_external_id)
|
||||||
|
DB --> Service: dbArtist
|
||||||
|
|
||||||
|
Service --> Server: MonitorAlbumResponse
|
||||||
|
note right: album: id, title, monitor_state=monitored,\n download: state, format, quality\nartist: id, name, monitor_state\nrelease: hash, format, seeders, tracks
|
||||||
|
Server --> Client: MonitorAlbumResponse
|
||||||
|
|
||||||
|
== 11. Async: Download Polling (River Worker) ==
|
||||||
|
|
||||||
|
River -> PollWorker: Work(PollDownloadArgs)
|
||||||
|
PollWorker -> QBit: Find(hash)
|
||||||
|
QBit --> PollWorker: TorrentInfo (progress, state, path)
|
||||||
|
|
||||||
|
alt progress < 100%
|
||||||
|
PollWorker -> River: Insert(PollDownloadArgs)
|
||||||
|
note right: Reschedule after check_interval
|
||||||
|
else progress == 100%
|
||||||
|
PollWorker -> DB: downloads.SetCompleted(id, save_path)
|
||||||
|
PollWorker -> PollWorker: scanAndHashFiles(content_path)
|
||||||
|
note right: Walk directory, identify audio files\n(.flac, .mp3, .aac, ...)\nSHA-256 hash each file
|
||||||
|
PollWorker -> DB: download_files.CreateBatch(files)
|
||||||
|
note right: file_path, file_size, file_type,\nsha256_hash, verified_at
|
||||||
|
end
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
@startuml MonitorAlbumStream - Already Owned Scenarios
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbumStream: Already Owned Handling
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "monitorWorkflow" as Workflow
|
||||||
|
participant "EventBus" as Bus
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
participant "IndexerService" as Indexer
|
||||||
|
|
||||||
|
note over Client, Indexer #lightblue
|
||||||
|
All events are persisted to album_events table (DB first)
|
||||||
|
then published to EventBus for live subscribers.
|
||||||
|
In automatic mode, workflow runs as background goroutine.
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Scenario A: Automatic Mode - Early Return ==
|
||||||
|
|
||||||
|
Client -> Workflow: StartMonitorRequest(mode=AUTOMATIC)
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
note right #lightblue: "Fetching album metadata..."
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
note right #lightblue: "Got: Artist - Title"\nData: StreamAlbumInfo
|
||||||
|
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
|
||||||
|
|
||||||
|
Workflow -> DB: downloads.HasAlbumInQuality()
|
||||||
|
DB --> Workflow: true
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(COMPLETE)
|
||||||
|
note right #lightgreen: "Already owned"
|
||||||
|
|
||||||
|
Workflow ->> Client: MonitorAlbumResponse
|
||||||
|
note right: album: monitored\ndownload: existing info\nrelease: nil (no search)
|
||||||
|
|
||||||
|
note over Client, Workflow #lightblue
|
||||||
|
**Automatic Mode**: Skips search entirely.
|
||||||
|
Returns immediately with existing download info.
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Scenario B: Manual Mode - User Confirms Continue ==
|
||||||
|
|
||||||
|
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
|
||||||
|
|
||||||
|
Workflow -> DB: downloads.HasAlbumInQuality()
|
||||||
|
DB --> Workflow: true
|
||||||
|
|
||||||
|
Workflow ->> Client: PromptForDecision
|
||||||
|
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false
|
||||||
|
|
||||||
|
Client -> Workflow: UserDecision(confirm=true)
|
||||||
|
note right #lightgreen: User chooses\nto continue
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
|
||||||
|
note right: Proceeds with normal flow...
|
||||||
|
|
||||||
|
Workflow -> Indexer: Search()
|
||||||
|
note right: ... continues to completion
|
||||||
|
|
||||||
|
== Scenario C: Manual Mode - User Skips ==
|
||||||
|
|
||||||
|
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
|
||||||
|
|
||||||
|
Workflow -> DB: downloads.HasAlbumInQuality()
|
||||||
|
DB --> Workflow: true
|
||||||
|
|
||||||
|
Workflow ->> Client: PromptForDecision
|
||||||
|
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"
|
||||||
|
|
||||||
|
Client -> Workflow: UserDecision(confirm=false)
|
||||||
|
note right #lightyellow: User chooses\nto skip
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(COMPLETE)
|
||||||
|
note right: "Skipped - already owned"
|
||||||
|
|
||||||
|
Workflow ->> Client: MonitorAlbumResponse
|
||||||
|
note right: album: monitored\ndownload: existing info\nrelease: nil
|
||||||
|
|
||||||
|
== Scenario D: Manual Mode - Timeout ==
|
||||||
|
|
||||||
|
Client -> Workflow: StartMonitorRequest(mode=MANUAL)
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
|
||||||
|
Workflow ->> Client: PromptForDecision
|
||||||
|
|
||||||
|
note over Client, Workflow #lightyellow
|
||||||
|
Client does not respond within timeout (max: 300s)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Workflow -> Workflow: Use default decision
|
||||||
|
note right: default: false\n(skip download)
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(COMPLETE)
|
||||||
|
note right: "Skipped - already owned"
|
||||||
|
|
||||||
|
Workflow ->> Client: MonitorAlbumResponse
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
@startuml MonitorAlbumStream - Automatic Mode Happy Path
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbumStream: Automatic Mode (Fire-and-Forget)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "gRPC Server" as Server
|
||||||
|
participant "WorkflowRegistry" as Registry
|
||||||
|
participant "EventBus" as Bus
|
||||||
|
participant "monitorWorkflow\n(background goroutine)" as Workflow
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
participant "MusicAgregatorService" as Service
|
||||||
|
participant "IndexerService" as Indexer
|
||||||
|
participant "MagnetResolver" as Magnet
|
||||||
|
participant "TorrentClient\n(qBittorrent)" as QBit
|
||||||
|
|
||||||
|
== 1. Initialize Stream ==
|
||||||
|
|
||||||
|
Client -> Server: MonitorAlbumStream()
|
||||||
|
Server -> Client: stream established
|
||||||
|
|
||||||
|
Client -> Server: StartMonitorRequest
|
||||||
|
note right: album_id, quality\nmode = AUTOMATIC
|
||||||
|
|
||||||
|
== 2. Start or Subscribe to Workflow ==
|
||||||
|
|
||||||
|
Server -> Registry: GetOrCreate(albumID, quality)
|
||||||
|
|
||||||
|
alt New workflow
|
||||||
|
Registry --> Server: (entry, created=true)
|
||||||
|
|
||||||
|
Server -> Bus: Subscribe(topic)
|
||||||
|
note right #lightblue: Subscribe BEFORE\nstarting goroutine\n(no missed events)
|
||||||
|
|
||||||
|
Server -> Workflow: go workflow.run(entry.Ctx)
|
||||||
|
note right #lightyellow: Background goroutine.\nDecoupled from stream context.\nClient can disconnect freely.
|
||||||
|
else Existing workflow
|
||||||
|
Registry --> Server: (entry, created=false)
|
||||||
|
|
||||||
|
Server -> Bus: Subscribe(topic)
|
||||||
|
|
||||||
|
Server -> DB: Replay album_events\n(since last seq)
|
||||||
|
DB --> Server: historical events
|
||||||
|
Server ->> Client: replayed events
|
||||||
|
end
|
||||||
|
|
||||||
|
== 3. Event Bridge (concurrent) ==
|
||||||
|
|
||||||
|
note over Client, Workflow #lightblue
|
||||||
|
**Left side**: Event bus → gRPC stream bridge
|
||||||
|
**Right side**: Workflow executing in background
|
||||||
|
Both run concurrently. Client disconnect only stops the bridge.
|
||||||
|
end note
|
||||||
|
|
||||||
|
|||
|
||||||
|
|
||||||
|
Workflow -> DB: album_events.Create(FETCHING_METADATA)
|
||||||
|
Workflow -> Bus: Publish(FETCHING_METADATA)
|
||||||
|
Bus ->> Server: event notification
|
||||||
|
Server ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
|
||||||
|
Workflow -> Service: getAlbumWithPersist()
|
||||||
|
Workflow -> DB: workflow_runs.Create(albumID, quality)
|
||||||
|
|
||||||
|
Workflow -> DB: album_events.Create(CHECKING_OWNED)
|
||||||
|
Workflow -> Bus: Publish(CHECKING_OWNED)
|
||||||
|
Bus ->> Server: event notification
|
||||||
|
Server ->> Client: StatusUpdate(CHECKING_OWNED)
|
||||||
|
|
||||||
|
Workflow -> Indexer: Search()
|
||||||
|
Workflow -> DB: album_events.Create(PARSING_RESULTS)
|
||||||
|
Workflow -> Bus: Publish(PARSING_RESULTS)
|
||||||
|
Bus ->> Server: event notification
|
||||||
|
Server ->> Client: StatusUpdate(PARSING_RESULTS)
|
||||||
|
|
||||||
|
Workflow -> Workflow: filterByQuality + selectBest
|
||||||
|
|
||||||
|
Workflow -> DB: saveTorrentAndDownload()
|
||||||
|
note right: DB save BEFORE qBit add\n(prevents orphan torrents)
|
||||||
|
Workflow -> QBit: AddMagnet()
|
||||||
|
|
||||||
|
Workflow -> DB: album_events.Create(COMPLETE)
|
||||||
|
Workflow -> Bus: Publish(COMPLETE)
|
||||||
|
Bus ->> Server: event notification
|
||||||
|
Server ->> Client: MonitorAlbumResponse
|
||||||
|
note right #lightgreen: Final result
|
||||||
|
|
||||||
|
Workflow -> DB: workflow_runs.SetCompleted()
|
||||||
|
Workflow -> Registry: Remove(albumID, quality)
|
||||||
|
|
||||||
|
== 4. Client Disconnect (Fire-and-Forget) ==
|
||||||
|
|
||||||
|
note over Client, Workflow #lightyellow
|
||||||
|
Client can disconnect at ANY point during the workflow.
|
||||||
|
The workflow goroutine continues independently.
|
||||||
|
Another client can subscribe to the same workflow later.
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
@startuml MonitorAlbumStream - Manual Mode Happy Path
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam responseMessageBelowArrow true
|
||||||
|
title MonitorAlbumStream: Manual Mode (Interactive Prompts)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "gRPC Server" as Server
|
||||||
|
participant "monitorWorkflow" as Workflow
|
||||||
|
participant "MusicAgregatorService" as Service
|
||||||
|
database "PostgreSQL" as DB
|
||||||
|
participant "IndexerService" as Indexer
|
||||||
|
participant "MagnetResolver" as Magnet
|
||||||
|
participant "TorrentClient\n(qBittorrent)" as QBit
|
||||||
|
|
||||||
|
== 1. Initialize Stream ==
|
||||||
|
|
||||||
|
Client -> Server: MonitorAlbumStream()
|
||||||
|
Server -> Client: stream established
|
||||||
|
|
||||||
|
Client -> Server: StartMonitorRequest
|
||||||
|
note right: album_id, quality\nmode = MANUAL
|
||||||
|
|
||||||
|
Server -> Workflow: newMonitorWorkflow()
|
||||||
|
|
||||||
|
== 2. Fetch Metadata ==
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
note right #lightblue: "Fetching album metadata..."
|
||||||
|
Workflow -> Service: getAlbumWithPersist()
|
||||||
|
Workflow ->> Client: StatusUpdate(FETCHING_METADATA)
|
||||||
|
note right #lightblue: Data: StreamAlbumInfo\n{artist, title, release_date,\nalready_owned, owned_quality}
|
||||||
|
|
||||||
|
== 3. Check Ownership (Interactive) ==
|
||||||
|
|
||||||
|
Workflow -> DB: downloads.HasAlbumInQuality()
|
||||||
|
DB --> Workflow: true (already owned!)
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(CHECKING_OWNED)
|
||||||
|
note right #lightyellow: "Already owned in FLAC quality"
|
||||||
|
|
||||||
|
Workflow ->> Client: PromptForDecision
|
||||||
|
note right #orange: type: CONFIRM\nmessage: "Album already owned. Download anyway?"\ndefault: false\ntimeout: max 300s
|
||||||
|
|
||||||
|
Client -> Server: UserDecision
|
||||||
|
note right: confirm: true\n(user chooses to continue)
|
||||||
|
|
||||||
|
Workflow -> Workflow: Continue with search
|
||||||
|
|
||||||
|
== 4. Search & Parse ==
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(SEARCHING_INDEXER)
|
||||||
|
Workflow -> Indexer: Search()
|
||||||
|
Indexer --> Workflow: 3 results
|
||||||
|
|
||||||
|
loop parse results
|
||||||
|
Workflow -> Magnet: Resolve()
|
||||||
|
end
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(PARSING_RESULTS)
|
||||||
|
note right #lightblue: Data: TorrentList\n[{id, title, seeders, format}, ...]
|
||||||
|
|
||||||
|
== 5. Select Torrents (Interactive) ==
|
||||||
|
|
||||||
|
Workflow ->> Client: PromptForDecision
|
||||||
|
note right #orange: type: SELECT_MANY\nmessage: "Select torrents to consider"\noptions: [{id, label, description}, ...]\ndefault: all selected\nmin: 1, max: N
|
||||||
|
|
||||||
|
Client -> Server: UserDecision
|
||||||
|
note right: selected_ids: ["torrent-0", "torrent-2"]\n(user deselects torrent-1)
|
||||||
|
|
||||||
|
Workflow -> Workflow: Filter to selected torrents
|
||||||
|
|
||||||
|
== 6. Filter by Quality ==
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(FILTERING_QUALITY)
|
||||||
|
Workflow -> Workflow: filterByQuality()
|
||||||
|
note right: 2 releases remain\nafter quality filter
|
||||||
|
|
||||||
|
== 7. Select Release (Interactive) ==
|
||||||
|
|
||||||
|
Workflow ->> Client: PromptForDecision
|
||||||
|
note right #orange: type: SELECT_ONE\nmessage: "Select release"\noptions: [{id, label, description}, ...]\ndefault: highest seeders
|
||||||
|
|
||||||
|
Client -> Server: UserDecision
|
||||||
|
note right: selected_id: "release-1"\n(user picks specific release)
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(SELECTING_RELEASE)
|
||||||
|
note right #lightblue: Data: ReleaseInfo\n{hash, format, seeders, tracker}
|
||||||
|
|
||||||
|
== 8. Confirm Add (Interactive) ==
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(ADDING_TORRENT)
|
||||||
|
note right #lightyellow: "Adding torrent: Title..."
|
||||||
|
|
||||||
|
Workflow ->> Client: PromptForDecision
|
||||||
|
note right #orange: type: CONFIRM\nmessage: "Add torrent 'Title' to client?"\nconfirm_label: "Add"\ncancel_label: "Skip"\ndefault: true
|
||||||
|
|
||||||
|
Client -> Server: UserDecision
|
||||||
|
note right: confirm: true
|
||||||
|
|
||||||
|
== 9. Add & Save ==
|
||||||
|
|
||||||
|
Workflow -> QBit: AddMagnet()
|
||||||
|
QBit --> Workflow: OK
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(SAVING)
|
||||||
|
Workflow -> DB: Create torrent & download
|
||||||
|
|
||||||
|
== 10. Complete ==
|
||||||
|
|
||||||
|
Workflow ->> Client: StatusUpdate(COMPLETE)
|
||||||
|
note right #lightblue: "Download started"
|
||||||
|
|
||||||
|
Workflow ->> Client: MonitorAlbumResponse
|
||||||
|
note right #lightgreen: Final result
|
||||||
|
|
||||||
|
== Cancel Cleanup (Disconnect or Cancel Message) ==
|
||||||
|
|
||||||
|
note over Client, QBit #salmon
|
||||||
|
**Manual Mode: Disconnect = Cancel**
|
||||||
|
|
||||||
|
When client disconnects or sends CancelRequest:
|
||||||
|
1. Workflow context is cancelled (stops further processing)
|
||||||
|
2. If torrent was added to qBit: **DeleteTorrent(hash)** removes it + files
|
||||||
|
3. If download record exists: marked as **cancelled** in DB
|
||||||
|
4. workflow_run marked as **cancelled** in DB
|
||||||
|
5. All events persisted to album_events for audit trail
|
||||||
|
|
||||||
|
Cleanup uses a fresh context (not the cancelled one).
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Decision Points Summary ==
|
||||||
|
|
||||||
|
note over Client, QBit #lightyellow
|
||||||
|
**Manual Mode Decision Points:**
|
||||||
|
|
||||||
|
1. **CHECKING_OWNED** (CONFIRM)
|
||||||
|
- Triggered when: Album already owned in requested quality
|
||||||
|
- Default: false (skip)
|
||||||
|
- Timeout action: Use default
|
||||||
|
|
||||||
|
2. **PARSING_RESULTS** (SELECT_MANY)
|
||||||
|
- Triggered when: Multiple torrents found (>1)
|
||||||
|
- Default: All selected
|
||||||
|
- Timeout action: Use defaults
|
||||||
|
|
||||||
|
3. **SELECTING_RELEASE** (SELECT_ONE)
|
||||||
|
- Triggered when: Multiple releases after quality filter (>1)
|
||||||
|
- Default: Highest seeders
|
||||||
|
- Timeout action: Use default
|
||||||
|
|
||||||
|
4. **ADDING_TORRENT** (CONFIRM)
|
||||||
|
- Triggered: Always in manual mode
|
||||||
|
- Default: true (add)
|
||||||
|
- Timeout action: Use default
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
@startuml MonitorAlbumStream Protocol
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
title MonitorAlbumStream & SubscribeEvents: Message Protocol
|
||||||
|
|
||||||
|
participant "Client" as C
|
||||||
|
participant "Server" as S
|
||||||
|
|
||||||
|
== MonitorAlbumStream (Bidirectional) ==
|
||||||
|
|
||||||
|
C -> S: gRPC MonitorAlbumStream()
|
||||||
|
note right: Opens bidirectional stream
|
||||||
|
|
||||||
|
== Request Messages (Client -> Server) ==
|
||||||
|
|
||||||
|
C -> S: MonitorAlbumStreamRequest
|
||||||
|
note right #lightblue
|
||||||
|
**oneof message:**
|
||||||
|
- **start**: StartMonitorRequest
|
||||||
|
- album_id (required)
|
||||||
|
- quality: LOSSLESS | LOSSY | UNSPECIFIED
|
||||||
|
- mode: AUTOMATIC (0) | MANUAL (1)
|
||||||
|
- indexer_options: {tracker}
|
||||||
|
- **decision**: UserDecision
|
||||||
|
- prompt_id (must match pending prompt)
|
||||||
|
- confirm | selected_id | selected_ids
|
||||||
|
- **cancel**: CancelRequest
|
||||||
|
- Gracefully terminates workflow
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Response Messages (Server -> Client) ==
|
||||||
|
|
||||||
|
S -> C: MonitorAlbumStreamResponse
|
||||||
|
note left #lightgreen
|
||||||
|
**oneof message:**
|
||||||
|
- **status**: StatusUpdate
|
||||||
|
- step: MonitorStep enum
|
||||||
|
- message: human-readable text
|
||||||
|
- data: StreamAlbumInfo | TorrentList | ReleaseInfo
|
||||||
|
- **prompt**: PromptForDecision
|
||||||
|
- prompt_id: unique identifier
|
||||||
|
- type: CONFIRM | SELECT_ONE | SELECT_MANY
|
||||||
|
- message: prompt text
|
||||||
|
- timeout_seconds: response deadline
|
||||||
|
- options: confirm | select_one | select_many config
|
||||||
|
- **result**: MonitorAlbumResponse
|
||||||
|
- Final response (stream ends after this)
|
||||||
|
- **error**: ErrorUpdate
|
||||||
|
- failed_step: where error occurred
|
||||||
|
- message: error description
|
||||||
|
- recoverable: bool
|
||||||
|
end note
|
||||||
|
|
||||||
|
== SubscribeEvents (Server-Side Stream) ==
|
||||||
|
|
||||||
|
C -> S: SubscribeEvents(SubscribeEventsRequest)
|
||||||
|
note right #lightyellow
|
||||||
|
**SubscribeEventsRequest:**
|
||||||
|
- since_seq: int64 (0 = live only, >0 = replay from seq)
|
||||||
|
|
||||||
|
**Response stream: AlbumEvent**
|
||||||
|
- seq: int64 (monotonic sequence number)
|
||||||
|
- workflow_run_id, album_id, quality
|
||||||
|
- event_type: status | error | result
|
||||||
|
- step: MonitorStep name
|
||||||
|
- message: human-readable text
|
||||||
|
- data_json: bytes (optional structured data)
|
||||||
|
- timestamp_ms: int64
|
||||||
|
|
||||||
|
**Global firehose**: receives events from ALL running workflows.
|
||||||
|
Client-side filtering by album_id if needed.
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Event Flow Architecture ==
|
||||||
|
|
||||||
|
note over C, S #lightblue
|
||||||
|
**Event Path (DB first, bus second):**
|
||||||
|
|
||||||
|
1. Workflow step executes
|
||||||
|
2. Event written to **album_events** table (synchronous, durable)
|
||||||
|
3. Event published to **EventBus** (async, ephemeral notification)
|
||||||
|
4. Bus fans out to subscribers:
|
||||||
|
- MonitorAlbumStream clients (per-topic)
|
||||||
|
- SubscribeEvents clients (global)
|
||||||
|
5. Subscribers convert event to proto and stream.Send()
|
||||||
|
|
||||||
|
**DB is source of truth. Bus is notification layer.**
|
||||||
|
Events are never lost, even if no subscribers are connected.
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Monitor Steps ==
|
||||||
|
|
||||||
|
note over C, S #lightyellow
|
||||||
|
**MonitorStep Enum:**
|
||||||
|
1. FETCHING_METADATA - Getting album info from metadata service
|
||||||
|
2. CHECKING_OWNED - Checking if already downloaded
|
||||||
|
3. SEARCHING_INDEXER - Querying Jackett/indexers
|
||||||
|
4. PARSING_RESULTS - Resolving magnets, parsing torrents
|
||||||
|
5. FILTERING_QUALITY - Applying quality filter
|
||||||
|
6. SELECTING_RELEASE - Choosing best/user-selected release
|
||||||
|
7. ADDING_TORRENT - Adding to qBittorrent
|
||||||
|
8. SAVING - Persisting to database
|
||||||
|
9. COMPLETE - Workflow finished
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Mode Comparison ==
|
||||||
|
|
||||||
|
note over C, S #lightyellow
|
||||||
|
**AUTOMATIC mode:**
|
||||||
|
- Workflow runs as background goroutine
|
||||||
|
- Client can disconnect, workflow continues (fire-and-forget)
|
||||||
|
- Duplicate request for same album+quality subscribes to existing
|
||||||
|
- Events delivered via EventBus bridge
|
||||||
|
|
||||||
|
**MANUAL mode:**
|
||||||
|
- Workflow runs inline in stream handler
|
||||||
|
- Interactive prompts at 4 decision points
|
||||||
|
- Disconnect = cancel = full cleanup (qBit delete + DB cancel)
|
||||||
|
- Events delivered directly via stream + persisted to DB
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Persistence ==
|
||||||
|
|
||||||
|
note over C, S #lightgreen
|
||||||
|
**workflow_runs table:**
|
||||||
|
- Tracks workflow lifecycle: running → completed | failed | cancelled
|
||||||
|
- Unique constraint: one running workflow per album+quality
|
||||||
|
- Used for deduplication, recovery, and audit
|
||||||
|
|
||||||
|
**album_events table:**
|
||||||
|
- Audit log of all workflow events
|
||||||
|
- seq BIGSERIAL for ordering and replay
|
||||||
|
- Supports subscribe-before-query replay pattern
|
||||||
|
end note
|
||||||
|
|
||||||
|
== Timeout Behavior ==
|
||||||
|
|
||||||
|
note over C, S #pink
|
||||||
|
When prompt times out (max: 300s):
|
||||||
|
- Server uses **default decision** value
|
||||||
|
- Workflow continues automatically
|
||||||
|
- No error is raised
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
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
|
||||||
@@ -16,259 +16,165 @@ skinparam package {
|
|||||||
|
|
||||||
title Music Aggregator - Database Structure
|
title Music Aggregator - Database Structure
|
||||||
|
|
||||||
' ══════════════════════════════════════════════════════════════
|
package "Music Metadata" #E3F2FD {
|
||||||
' CORE MUSIC ENTITIES
|
entity "artists" {
|
||||||
' ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
package "Core Music Entities" #E3F2FD {
|
|
||||||
entity "artist_metadata" {
|
|
||||||
* id : UUID <<PK>>
|
* id : UUID <<PK>>
|
||||||
--
|
--
|
||||||
foreign_artist_id : TEXT <<UNIQUE>>
|
external_id : VARCHAR(255) <<UNIQUE>>
|
||||||
name : TEXT
|
name : VARCHAR(500)
|
||||||
sort_name : TEXT
|
artist_type : VARCHAR(50)
|
||||||
disambiguation : TEXT
|
country : VARCHAR(10)
|
||||||
artist_type : TEXT
|
genres : TEXT[]
|
||||||
status : TEXT
|
image_url : TEXT
|
||||||
overview : TEXT
|
|
||||||
images : JSONB
|
|
||||||
links : JSONB
|
|
||||||
genres : JSONB
|
|
||||||
--
|
--
|
||||||
created_at : TIMESTAMPTZ
|
created_at : TIMESTAMPTZ
|
||||||
updated_at : TIMESTAMPTZ
|
updated_at : TIMESTAMPTZ
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "artists" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
metadata_id : UUID <<FK>>
|
|
||||||
quality_profile_id : UUID <<FK>>
|
|
||||||
metadata_profile_id : UUID <<FK>>
|
|
||||||
root_folder_id : UUID <<FK>>
|
|
||||||
--
|
|
||||||
path : TEXT
|
|
||||||
monitored : BOOLEAN
|
|
||||||
monitor_new_items : TEXT
|
|
||||||
--
|
|
||||||
last_info_sync : TIMESTAMPTZ
|
|
||||||
added_at : TIMESTAMPTZ
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "albums" {
|
entity "albums" {
|
||||||
* id : UUID <<PK>>
|
* id : UUID <<PK>>
|
||||||
--
|
--
|
||||||
artist_metadata_id : UUID <<FK>>
|
external_id : VARCHAR(255) <<UNIQUE>>
|
||||||
|
artist_id : UUID <<FK>>
|
||||||
--
|
--
|
||||||
foreign_album_id : TEXT <<UNIQUE>>
|
title : VARCHAR(500)
|
||||||
title : TEXT
|
album_type : VARCHAR(50)
|
||||||
clean_title : TEXT
|
|
||||||
disambiguation : TEXT
|
|
||||||
overview : TEXT
|
|
||||||
album_type : TEXT
|
|
||||||
release_date : DATE
|
release_date : DATE
|
||||||
images : JSONB
|
total_tracks : INT
|
||||||
genres : JSONB
|
total_discs : INT
|
||||||
|
label : VARCHAR(255)
|
||||||
|
genres : TEXT[]
|
||||||
|
cover_url : TEXT
|
||||||
|
is_monitored : BOOLEAN
|
||||||
--
|
--
|
||||||
monitored : BOOLEAN
|
created_at : TIMESTAMPTZ
|
||||||
added_at : TIMESTAMPTZ
|
updated_at : TIMESTAMPTZ
|
||||||
}
|
|
||||||
|
|
||||||
entity "album_releases" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
album_id : UUID <<FK>>
|
|
||||||
--
|
|
||||||
foreign_release_id : TEXT <<UNIQUE>>
|
|
||||||
title : TEXT
|
|
||||||
status : TEXT
|
|
||||||
duration_ms : INT
|
|
||||||
release_date : DATE
|
|
||||||
country : TEXT[]
|
|
||||||
label : TEXT[]
|
|
||||||
format : TEXT
|
|
||||||
track_count : INT
|
|
||||||
--
|
|
||||||
monitored : BOOLEAN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "tracks" {
|
entity "tracks" {
|
||||||
* id : UUID <<PK>>
|
* id : UUID <<PK>>
|
||||||
--
|
--
|
||||||
album_release_id : UUID <<FK>>
|
external_id : VARCHAR(255) <<UNIQUE>>
|
||||||
artist_metadata_id : UUID <<FK>>
|
album_id : UUID <<FK>>
|
||||||
track_file_id : UUID <<FK NULL>>
|
|
||||||
--
|
--
|
||||||
foreign_track_id : TEXT <<UNIQUE>>
|
title : VARCHAR(500)
|
||||||
title : TEXT
|
|
||||||
track_number : INT
|
|
||||||
disc_number : INT
|
|
||||||
duration_ms : INT
|
duration_ms : INT
|
||||||
explicit : BOOLEAN
|
isrc : VARCHAR(20)
|
||||||
|
disc_number : INT
|
||||||
|
track_number : INT
|
||||||
|
--
|
||||||
|
created_at : TIMESTAMPTZ
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "track_files" {
|
package "Torrent Catalog" #FFF3E0 {
|
||||||
|
entity "torrents" {
|
||||||
* id : UUID <<PK>>
|
* id : UUID <<PK>>
|
||||||
--
|
--
|
||||||
album_id : UUID <<FK>>
|
album_id : UUID <<FK>>
|
||||||
|
info_hash : VARCHAR(40) <<UNIQUE>>
|
||||||
--
|
--
|
||||||
path : TEXT
|
tracker : VARCHAR(100)
|
||||||
relative_path : TEXT
|
|
||||||
size : BIGINT
|
|
||||||
--
|
|
||||||
file_hash : TEXT
|
|
||||||
audio_hash : TEXT
|
|
||||||
--
|
|
||||||
quality : JSONB
|
|
||||||
media_info : JSONB
|
|
||||||
--
|
|
||||||
scene_name : TEXT
|
|
||||||
release_group : TEXT
|
|
||||||
--
|
|
||||||
date_added : TIMESTAMPTZ
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
' ══════════════════════════════════════════════════════════════
|
|
||||||
' CONFIGURATION
|
|
||||||
' ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
package "Configuration" #FFF3E0 {
|
|
||||||
entity "quality_profiles" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
name : TEXT <<UNIQUE>>
|
|
||||||
cutoff : INT
|
|
||||||
items : JSONB
|
|
||||||
upgrade_allowed : BOOLEAN
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "metadata_profiles" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
name : TEXT <<UNIQUE>>
|
|
||||||
primary_album_types : JSONB
|
|
||||||
secondary_album_types : JSONB
|
|
||||||
release_statuses : JSONB
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "root_folders" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
name : TEXT
|
|
||||||
path : TEXT <<UNIQUE>>
|
|
||||||
default_quality_profile_id : UUID <<FK>>
|
|
||||||
default_metadata_profile_id : UUID <<FK>>
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "indexers" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
name : TEXT
|
|
||||||
implementation : TEXT
|
|
||||||
settings : JSONB
|
|
||||||
enable_rss : BOOLEAN
|
|
||||||
enable_search : BOOLEAN
|
|
||||||
priority : INT
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "download_clients" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
name : TEXT
|
|
||||||
implementation : TEXT
|
|
||||||
settings : JSONB
|
|
||||||
protocol : TEXT
|
|
||||||
priority : INT
|
|
||||||
enabled : BOOLEAN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
' ══════════════════════════════════════════════════════════════
|
|
||||||
' DOWNLOAD TRACKING
|
|
||||||
' ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
package "Download Tracking" #E8F5E9 {
|
|
||||||
entity "wanted_albums" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
album_id : UUID <<FK>> <<UNIQUE>>
|
|
||||||
--
|
|
||||||
priority : INT
|
|
||||||
search_count : INT
|
|
||||||
last_searched_at : TIMESTAMPTZ
|
|
||||||
added_at : TIMESTAMPTZ
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "download_queue" {
|
|
||||||
* id : UUID <<PK>>
|
|
||||||
--
|
|
||||||
artist_id : UUID <<FK>>
|
|
||||||
album_id : UUID <<FK>>
|
|
||||||
--
|
|
||||||
download_id : TEXT
|
|
||||||
title : TEXT
|
title : TEXT
|
||||||
|
format : VARCHAR(20)
|
||||||
|
quality : VARCHAR(20)
|
||||||
|
source : VARCHAR(20)
|
||||||
|
bit_depth : INT
|
||||||
|
sample_rate : INT
|
||||||
|
seeders : INT
|
||||||
|
peers : INT
|
||||||
size : BIGINT
|
size : BIGINT
|
||||||
size_left : BIGINT
|
track_count : INT
|
||||||
|
has_cover_art : BOOLEAN
|
||||||
|
has_cue_sheet : BOOLEAN
|
||||||
|
has_rip_log : BOOLEAN
|
||||||
|
download_link : TEXT
|
||||||
|
torrent_file : BYTEA
|
||||||
--
|
--
|
||||||
status : TEXT
|
created_at : TIMESTAMPTZ
|
||||||
progress : REAL
|
updated_at : TIMESTAMPTZ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Download Management" #E8F5E9 {
|
||||||
|
entity "downloads" {
|
||||||
|
* id : UUID <<PK>>
|
||||||
|
--
|
||||||
|
torrent_id : UUID <<FK>>
|
||||||
|
album_id : UUID
|
||||||
|
format : VARCHAR(20)
|
||||||
|
quality : VARCHAR(20)
|
||||||
|
--
|
||||||
|
state : download_state
|
||||||
|
qbit_hash : VARCHAR(64)
|
||||||
|
save_path : TEXT
|
||||||
error_message : TEXT
|
error_message : TEXT
|
||||||
--
|
--
|
||||||
protocol : TEXT
|
queued_at : TIMESTAMPTZ
|
||||||
indexer : TEXT
|
started_at : TIMESTAMPTZ
|
||||||
download_client : TEXT
|
|
||||||
torrent_hash : TEXT
|
|
||||||
output_path : TEXT
|
|
||||||
--
|
|
||||||
added_at : TIMESTAMPTZ
|
|
||||||
completed_at : TIMESTAMPTZ
|
completed_at : TIMESTAMPTZ
|
||||||
|
created_at : TIMESTAMPTZ
|
||||||
|
updated_at : TIMESTAMPTZ
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "blocklist" {
|
entity "download_files" {
|
||||||
* id : UUID <<PK>>
|
* id : UUID <<PK>>
|
||||||
--
|
--
|
||||||
artist_id : UUID <<FK>>
|
download_id : UUID <<FK>>
|
||||||
album_id : UUID <<FK>>
|
track_id : UUID <<FK NULL>>
|
||||||
--
|
--
|
||||||
source_title : TEXT
|
file_path : TEXT
|
||||||
quality : JSONB
|
file_size : BIGINT
|
||||||
size : BIGINT
|
file_type : VARCHAR(20)
|
||||||
protocol : TEXT
|
sha256_hash : VARCHAR(64)
|
||||||
indexer : TEXT
|
|
||||||
message : TEXT
|
|
||||||
torrent_hash : TEXT
|
|
||||||
--
|
--
|
||||||
date : TIMESTAMPTZ
|
verified_at : TIMESTAMPTZ
|
||||||
|
created_at : TIMESTAMPTZ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
' ══════════════════════════════════════════════════════════════
|
package "Caching & Queue (River)" #F3E5F5 {
|
||||||
' RELATIONSHIPS
|
entity "river_job" {
|
||||||
' ══════════════════════════════════════════════════════════════
|
* id : BIGSERIAL <<PK>>
|
||||||
|
--
|
||||||
|
kind : TEXT
|
||||||
|
state : river_job_state
|
||||||
|
queue : TEXT
|
||||||
|
args : JSONB
|
||||||
|
metadata : JSONB
|
||||||
|
--
|
||||||
|
attempt : SMALLINT
|
||||||
|
max_attempts : SMALLINT
|
||||||
|
priority : SMALLINT
|
||||||
|
--
|
||||||
|
scheduled_at : TIMESTAMPTZ
|
||||||
|
attempted_at : TIMESTAMPTZ
|
||||||
|
created_at : TIMESTAMPTZ
|
||||||
|
finalized_at : TIMESTAMPTZ
|
||||||
|
}
|
||||||
|
|
||||||
' Core music relationships
|
entity "river_queue" {
|
||||||
artist_metadata ||--|| artists : "has config"
|
* name : TEXT <<PK>>
|
||||||
artist_metadata ||--o{ albums : "released"
|
--
|
||||||
albums ||--o{ album_releases : "has releases"
|
metadata : JSONB
|
||||||
album_releases ||--o{ tracks : "contains"
|
paused_at : TIMESTAMPTZ
|
||||||
tracks }o--o| track_files : "stored in"
|
created_at : TIMESTAMPTZ
|
||||||
track_files }o--|| albums : "belongs to"
|
updated_at : TIMESTAMPTZ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
' Artist config relationships
|
note right of river_job
|
||||||
artists }o--|| quality_profiles : "uses"
|
Cache refresh jobs:
|
||||||
artists }o--o| metadata_profiles : "uses"
|
kind = "indexer_cache_refresh"
|
||||||
artists }o--o| root_folders : "stored in"
|
args = {key, url, ttl_expires, refresh_interval}
|
||||||
|
scheduled_at = next refresh time
|
||||||
|
end note
|
||||||
|
|
||||||
' Root folder defaults
|
artists ||--o{ albums : "released"
|
||||||
root_folders }o--o| quality_profiles : "default"
|
albums ||--o{ tracks : "contains"
|
||||||
root_folders }o--o| metadata_profiles : "default"
|
albums ||--o{ torrents : "available on"
|
||||||
|
torrents ||--o| downloads : "downloaded as"
|
||||||
' Download tracking relationships
|
downloads ||--o{ download_files : "consists of"
|
||||||
wanted_albums ||--|| albums : "targets"
|
tracks ||--o| download_files : "matched to"
|
||||||
download_queue }o--o| artists : "for"
|
|
||||||
download_queue }o--o| albums : "for"
|
|
||||||
blocklist }o--|| artists : "for"
|
|
||||||
blocklist }o--o| albums : "for"
|
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Общие требования к раздачам в разделе КЛАССИЧЕСКОЙ музыки RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=773016
|
||||||
|
|
||||||
|
Для разделов: lossy, lossless, видео, DVD, Hi-Res, оцифровки, классика в современной обработке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Общие положения
|
||||||
|
|
||||||
|
Данные правила дополняют общие правила музыкальных разделов и применяются ко всем подразделам классической музыки.
|
||||||
|
|
||||||
|
## 2. Определение повторов
|
||||||
|
|
||||||
|
### 2.1 Проверка перед созданием
|
||||||
|
Обязательно использовать поиск по трекеру.
|
||||||
|
|
||||||
|
### 2.2 Что считается повтором
|
||||||
|
Материал, не отличающийся в лучшую сторону по качеству.
|
||||||
|
|
||||||
|
### 2.3 Что НЕ считается повтором
|
||||||
|
- lossy при наличии lossless и наоборот
|
||||||
|
- Rip при наличии DVD и наоборот
|
||||||
|
- **Одно произведение с разным составом исполнителей**
|
||||||
|
- **Одно произведение с разной датой записи**
|
||||||
|
- Одна запись разных лейблов (при существенных отличиях ремастеринга)
|
||||||
|
|
||||||
|
### 2.4 Перекрёстные раздачи
|
||||||
|
🚫 Запрещены.
|
||||||
|
|
||||||
|
## 3. Требования к наполнению
|
||||||
|
|
||||||
|
### 3.1 Одна раздача = одно издание
|
||||||
|
🚫 Запрещено помещать несколько официальных изданий в одну lossless-раздачу.
|
||||||
|
|
||||||
|
### Многодисковые издания:
|
||||||
|
- **Box-set** - диски в одной коробке
|
||||||
|
- **Серия** - с явным указанием принадлежности на обложке/сайте
|
||||||
|
|
||||||
|
### 3.2 Исключения
|
||||||
|
|
||||||
|
#### 3.2.1 Дискографии исполнителей
|
||||||
|
Отдельные альбомы популярных академических исполнителей.
|
||||||
|
|
||||||
|
> **Альбом** - набор композиций, подобранный специально для издания.
|
||||||
|
> **Не альбом** - запись отдельного произведения (например, "Риголетто").
|
||||||
|
|
||||||
|
#### 3.2.2 Полные циклы
|
||||||
|
- Все симфонии композитора
|
||||||
|
- Все концерты
|
||||||
|
- Все квартеты
|
||||||
|
- Циклы по замыслу автора ("Кольцо нибелунга", "Времена года")
|
||||||
|
|
||||||
|
При одинаковом исполнительском составе.
|
||||||
|
|
||||||
|
### 3.3 Сборники
|
||||||
|
🚫 Запрещены сборники без чёткой темы или с разнородным содержанием.
|
||||||
|
|
||||||
|
### 3.4 Минимум треков
|
||||||
|
🚫 Один трек запрещён (кроме самодостаточных произведений).
|
||||||
|
|
||||||
|
### 3.5 Полнота
|
||||||
|
🚫 Запрещены неполные рипы или разделение диска.
|
||||||
|
|
||||||
|
### 3.6 Целостность
|
||||||
|
🚫 Запрещено разделять произведение на несколько раздач.
|
||||||
|
|
||||||
|
### 3.7 Качество записи
|
||||||
|
🚫 Запрещены записи с телефона/диктофона.
|
||||||
|
|
||||||
|
### 3.8 Битрейт
|
||||||
|
🚫 MP3 < 192 kbps
|
||||||
|
🚫 Срез частот < 16 кГц
|
||||||
|
🚫 Частота дискретизации < 44 кГц
|
||||||
|
|
||||||
|
✅ Исключение: редкие/раритетные записи (решение модератора).
|
||||||
|
|
||||||
|
## 4. Требования к заголовкам
|
||||||
|
|
||||||
|
### 4.1 Обязательное содержание
|
||||||
|
```
|
||||||
|
Композитор - Название произведения (Исполнитель)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Сборники по композитору
|
||||||
|
```
|
||||||
|
Композитор - Название сборника, Произведения (Исполнители)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Сборники по исполнителю
|
||||||
|
```
|
||||||
|
Название сборника - Композиторы, Произведения (Исполнитель)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Прочие сборники
|
||||||
|
```
|
||||||
|
Название сборника - Композиторы, Произведения
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Разделитель
|
||||||
|
Тире между композитором и произведением.
|
||||||
|
|
||||||
|
### 4.6 Язык
|
||||||
|
Язык оригинала (родной язык композитора или авторское название).
|
||||||
|
|
||||||
|
### 4.7 Диакритика
|
||||||
|
Дублировать латиницей при умляутах.
|
||||||
|
|
||||||
|
### 4.8 Перевод
|
||||||
|
Рекомендуется русский перевод через `/`.
|
||||||
|
|
||||||
|
### Пример:
|
||||||
|
```
|
||||||
|
(Classical, Opera) Rossini - Il barbiere di Siviglia / Россини - Севильский цирюльник (B. Sills, Н. Гедда, LSO, James Levine) - 1975, APE (image+.cue) lossless
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.9 Запреты
|
||||||
|
🚫 Сокращения (кроме № и #)
|
||||||
|
🚫 CAPS LOCK
|
||||||
|
🚫 Точки (кроме пунктуации)
|
||||||
|
|
||||||
|
## 5. Жанры классической музыки
|
||||||
|
|
||||||
|
### Обязательный тег
|
||||||
|
**Classical** - для всех раздач
|
||||||
|
|
||||||
|
### Вокальное искусство:
|
||||||
|
- **Opera** - опера
|
||||||
|
- **Choral** - хоровая музыка
|
||||||
|
- **Vocal** - произведения для голоса
|
||||||
|
|
||||||
|
### Оркестровая музыка:
|
||||||
|
- **Symphony** - симфонии
|
||||||
|
- **Concerto** - концерты для солиста с оркестром
|
||||||
|
- **Orchestral** - увертюры, сюиты, поэмы, балеты
|
||||||
|
|
||||||
|
### Камерная музыка:
|
||||||
|
- **Chamber** - сонаты, дуэты, трио, квартеты
|
||||||
|
|
||||||
|
### Сольная музыка:
|
||||||
|
- **Piano** - фортепиано
|
||||||
|
- **Organ** - орган
|
||||||
|
- **Violin** - скрипка
|
||||||
|
- **Cello** - виолончель
|
||||||
|
- **Guitar** - классическая гитара
|
||||||
|
- **Harp** - арфа
|
||||||
|
- **Flute** - флейта
|
||||||
|
|
||||||
|
### Эпохи:
|
||||||
|
- **Medieval** - XI–XIV века
|
||||||
|
- **Renaissance** - 1400–1600
|
||||||
|
- **Baroque** - 1600–1750
|
||||||
|
- **Romantic** - XIX–нач. XX века
|
||||||
|
- **Avantgarde** - нач.–сер. XX века
|
||||||
|
- **Minimalism** - с 1964 года
|
||||||
|
|
||||||
|
### Видео:
|
||||||
|
- **Ballet** - классический балет
|
||||||
|
- **Dance** - современный балет
|
||||||
|
- **Concert** - концерты
|
||||||
|
- **Documentary** - фильмы, мастер-классы
|
||||||
|
|
||||||
|
## 6. Треклист и исполнители
|
||||||
|
|
||||||
|
### 6.1 Обязательность
|
||||||
|
Треклист и список исполнителей **строго обязательны**.
|
||||||
|
|
||||||
|
### 6.2 Соответствие
|
||||||
|
Должны точно соответствовать содержанию.
|
||||||
|
|
||||||
|
### 6.3 Содержание треклиста
|
||||||
|
- Фамилия композитора
|
||||||
|
- Название произведения
|
||||||
|
|
||||||
|
### 6.4 Формат исполнителей
|
||||||
|
```
|
||||||
|
Солисты (партии), Хор, Оркестр/Ансамбль, Дирижёр
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Крупные формы
|
||||||
|
Для опер, симфоний - допускается указание диапазона треков:
|
||||||
|
```
|
||||||
|
1-4. Chopin: Piano Sonata No.2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Фрагменты
|
||||||
|
Указывать полное название + название фрагмента:
|
||||||
|
```
|
||||||
|
01. Nessun dorma! - Turandot (Puccini)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 Дополнительно
|
||||||
|
- Лейбл
|
||||||
|
- Дата и место записи
|
||||||
|
- Продолжительность (чч:мм:сс)
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Opus номер
|
||||||
|
Op\.\s*(\d+)(?:\s*[Nn]o\.\s*(\d+))?
|
||||||
|
|
||||||
|
# BWV/KV/D номера
|
||||||
|
(?:BWV|KV|K\.|D\.|Op\.)\s*(\d+)
|
||||||
|
|
||||||
|
# Формат названия произведения
|
||||||
|
(Symphony|Concerto|Sonata|Quartet|Suite|Overture)\s*(?:No\.\s*)?(\d+)
|
||||||
|
|
||||||
|
# Тональность
|
||||||
|
in\s+([A-G])\s*(major|minor|[-♯♭]?\s*(?:dur|moll))?
|
||||||
|
|
||||||
|
# Исполнитель с оркестром
|
||||||
|
([^,]+),\s*([^,]+(?:Orchestra|Philharmonic|Symphony))[,;]\s*([^)]+)
|
||||||
|
|
||||||
|
# Композитор - Произведение
|
||||||
|
^([A-Za-zА-Яа-яёÄÖÜäöüß\s]+)\s*[-–]\s*(.+)$
|
||||||
|
```
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
- **28.07.2014** - п. 6.5.1
|
||||||
|
- **17.03.2016** - пп. 3.1.1, 3.1.2, добавлен 3.9
|
||||||
|
- **11.07.2022** - пп. 4.2.2, 4.2.3
|
||||||
|
- **07.06.2023** - пп. 4.1, 4.6, 5.2; объединены 4.7-4.9
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Разъяснения по дискографиям и коллекциям RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372771
|
||||||
|
|
||||||
|
## Определения
|
||||||
|
|
||||||
|
### Дискография (Discography)
|
||||||
|
- Содержит слово "Дискография" или "Discography"
|
||||||
|
- Включает релизы только под **ОДНИМ** именем исполнителя
|
||||||
|
- Формат количества: `(15 CD)`, `(20 albums)`, `(10 releases)`
|
||||||
|
- Формат годов: `[1990–2020]` (используется длинное тире)
|
||||||
|
|
||||||
|
### Коллекция (Collection)
|
||||||
|
- Содержит слово "Коллекция" или "Collection"
|
||||||
|
- Может включать **несколько** имён/псевдонимов одного артиста
|
||||||
|
- Включает сайд-проекты, сольные работы, коллаборации
|
||||||
|
- Тот же формат количества и годов
|
||||||
|
|
||||||
|
## Ключевые различия
|
||||||
|
|
||||||
|
| Критерий | Дискография | Коллекция |
|
||||||
|
|----------|-------------|-----------|
|
||||||
|
| Имена артиста | Только одно | Несколько разрешено |
|
||||||
|
| Псевдонимы | Не включены | Включены |
|
||||||
|
| Сайд-проекты | Отдельно | Включены |
|
||||||
|
| Коллаборации | Отдельно | Включены |
|
||||||
|
|
||||||
|
## Формат заголовка
|
||||||
|
|
||||||
|
### Дискография:
|
||||||
|
```
|
||||||
|
[Artist] - Дискография (15 CD) [1990–2020, Rock, MP3, 320 kbps]
|
||||||
|
[Artist] - Discography (30 releases) [1985–2023, Electronic, FLAC]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Коллекция:
|
||||||
|
```
|
||||||
|
[Artist] - Коллекция (50 CD) [1980–2019, Various, FLAC]
|
||||||
|
[Artist] - Collection (100 albums) [1975–2025, Rock, MP3, VBR]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правила сайд-проектов
|
||||||
|
|
||||||
|
### Когда включать в коллекцию:
|
||||||
|
- Артист "определяет лицо" коллектива
|
||||||
|
- Артист - основной автор/вокалист
|
||||||
|
- Проект широко ассоциируется с артистом
|
||||||
|
|
||||||
|
### Когда выделять отдельно:
|
||||||
|
- Равноправное участие нескольких артистов
|
||||||
|
- Артист - приглашённый участник
|
||||||
|
- Проект имеет собственную идентичность
|
||||||
|
|
||||||
|
## Неофициальные релизы
|
||||||
|
|
||||||
|
🚫 **Запрещено** включать в дискографии/коллекции:
|
||||||
|
- Бутлеги
|
||||||
|
- Фан-компиляции
|
||||||
|
- Неофициальные сборники
|
||||||
|
- Самодельные ремастеры
|
||||||
|
|
||||||
|
✅ **Разрешено**:
|
||||||
|
- Официальные альбомы
|
||||||
|
- Официальные синглы и EP
|
||||||
|
- Официальные компиляции
|
||||||
|
- Официальные переиздания
|
||||||
|
|
||||||
|
## Формат папок
|
||||||
|
|
||||||
|
```
|
||||||
|
Artist Name/
|
||||||
|
├── 1990 - Album One/
|
||||||
|
├── 1992 - Album Two/
|
||||||
|
├── 1995 - Album Three (Remaster 2010)/
|
||||||
|
└── 2020 - Latest Album/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Требования к оформлению
|
||||||
|
|
||||||
|
### Обязательно для каждого релиза:
|
||||||
|
- Название альбома
|
||||||
|
- Год выпуска
|
||||||
|
- Каталожный номер (если есть)
|
||||||
|
- Жанр
|
||||||
|
- Треклист
|
||||||
|
- Продолжительность
|
||||||
|
|
||||||
|
### Под спойлером для каждого альбома:
|
||||||
|
```bbcode
|
||||||
|
[spoiler="Album Name (Year)"]
|
||||||
|
Каталог: LABEL001
|
||||||
|
Жанр: Rock
|
||||||
|
Продолжительность: 45:30
|
||||||
|
|
||||||
|
Треклист:
|
||||||
|
01. Track One (4:30)
|
||||||
|
02. Track Two (5:15)
|
||||||
|
...
|
||||||
|
[/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поглощение
|
||||||
|
|
||||||
|
- Дискография поглощает отдельные альбомы того же качества
|
||||||
|
- Коллекция поглощает дискографию + сайд-проекты
|
||||||
|
- Более качественная версия поглощает менее качественную
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Дискография
|
||||||
|
[Дд]искографи[яи]|[Dd]iscograph(?:y|ies)
|
||||||
|
|
||||||
|
# Коллекция
|
||||||
|
[Кк]оллекци[яи]|[Cc]ollection
|
||||||
|
|
||||||
|
# Количество релизов
|
||||||
|
\((\d+)\s*(?:CD|albums?|releases?|релиз(?:а|ов)?|альбом(?:а|ов)?)\)
|
||||||
|
|
||||||
|
# Диапазон годов (с длинным тире)
|
||||||
|
\[(\d{4})[–-](\d{4})
|
||||||
|
|
||||||
|
# Жанр в скобках
|
||||||
|
,\s*([A-Za-z\s,/]+),
|
||||||
|
```
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Общие правила публикации и оформления раздач RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372776
|
||||||
|
**Версия:** © 2023
|
||||||
|
**Последнее обновление:** 08-Дек-25
|
||||||
|
|
||||||
|
Действует для категорий: Музыка, Популярная музыка, Джазовая и Блюзовая музыка, Рок-музыка, Электронная музыка, Hi-Res форматы, оцифровки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Введение
|
||||||
|
|
||||||
|
### 1.1 Связанные правила
|
||||||
|
- [Правила для lossless](https://rutracker.org/forum/viewtopic.php?t=6372775)
|
||||||
|
- [Правила для lossy (MP3)](https://rutracker.org/forum/viewtopic.php?t=6372774)
|
||||||
|
- [Правила для Hi-Res](https://rutracker.org/forum/viewtopic.php?t=5093969)
|
||||||
|
- [Правила оцифровок](https://rutracker.org/forum/viewtopic.php?t=3558517)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Подготовка к созданию раздачи
|
||||||
|
|
||||||
|
### 2.1 Проверка жанра и формата
|
||||||
|
Убедитесь в соответствии выбранному подразделу.
|
||||||
|
|
||||||
|
### 2.2 Поиск повторов
|
||||||
|
**Обязательно** проверить через поиск по трекеру.
|
||||||
|
|
||||||
|
Повтор - материал, не отличающийся в лучшую сторону по качеству.
|
||||||
|
|
||||||
|
**Не считается повтором:**
|
||||||
|
- lossy при наличии lossless и наоборот
|
||||||
|
- Различные варианты мастеринга одного альбома
|
||||||
|
|
||||||
|
### 2.3 Запрещённые раздачи
|
||||||
|
- 🚫 Сборники собственного составления
|
||||||
|
- 🚫 WEB-сборники для стриминга (Deezer, Spotify)
|
||||||
|
- 🚫 Единичные треки, не изданные официально
|
||||||
|
- ✅ Любительские ремастеринги - в соответствующем подразделе
|
||||||
|
- ✅ AI-музыка - с дополнительными требованиями
|
||||||
|
|
||||||
|
### 2.4 Немузыкальный материал
|
||||||
|
🚫 Интервью - только в подразделе Аудио.
|
||||||
|
|
||||||
|
### 2.5 Смешение форматов
|
||||||
|
🚫 Запрещено объединять lossless и lossy (кроме официальных изданий).
|
||||||
|
|
||||||
|
### 2.6 Смешение Hi-Res и стандартного качества
|
||||||
|
🚫 Запрещено объединять Red Book с Hi-Res и оцифровками.
|
||||||
|
|
||||||
|
### 2.7 Неполные релизы
|
||||||
|
🚫 Запрещены неполные релизы и отдельные части многодисковых изданий.
|
||||||
|
|
||||||
|
### 2.8 Транскоды (апконверты)
|
||||||
|
🚫 Запрещены:
|
||||||
|
1. lossy → lossless
|
||||||
|
2. lossy → другой lossy кодек
|
||||||
|
3. Перекодирование с изменением битрейта
|
||||||
|
4. lossless низкого качества → lossless высокого (16/44.1 → 24/96)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Требования к содержимому
|
||||||
|
|
||||||
|
### 3.1 Запрещённые форматы образов
|
||||||
|
🚫 *.mdf/*.mds, *.iso (кроме iso.wv), *.nrg, *.bin+*.cue
|
||||||
|
🚫 Архивы (*.rar, *.zip)
|
||||||
|
|
||||||
|
### 3.2 Запрещённые файлы
|
||||||
|
🚫 *.exe, *.com, *.nfo, *.sfv
|
||||||
|
🚫 Файлы с рекламой сторонних ресурсов
|
||||||
|
✅ Плейлисты *.m3u, *.m3u8
|
||||||
|
|
||||||
|
### 3.3 Название папки
|
||||||
|
|
||||||
|
**Единичный релиз:**
|
||||||
|
```
|
||||||
|
Артист - Год - Альбом
|
||||||
|
Артист - Альбом - (Год)
|
||||||
|
```
|
||||||
|
❌ Неправильно: "Артист - Альбом", "ГодАльбом"
|
||||||
|
|
||||||
|
**Дискография:**
|
||||||
|
```
|
||||||
|
Артист/
|
||||||
|
├── Год - Альбом/
|
||||||
|
├── Год - Альбом/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Название файлов
|
||||||
|
|
||||||
|
**Альбом одного исполнителя:**
|
||||||
|
```
|
||||||
|
01 - Название
|
||||||
|
01. Название
|
||||||
|
A1 - Название (для винила)
|
||||||
|
```
|
||||||
|
❌ Неправильно: "Название - 01", "01Название"
|
||||||
|
|
||||||
|
**Сборник разных исполнителей:**
|
||||||
|
```
|
||||||
|
01. Исполнитель - Название
|
||||||
|
01 - Исполнитель - Название
|
||||||
|
```
|
||||||
|
|
||||||
|
**Нумерация:**
|
||||||
|
- 10+ треков: 01, 02 ... 09, 10
|
||||||
|
- 100+ треков: 001, 002 ... 099, 100
|
||||||
|
|
||||||
|
### 3.5 Теги
|
||||||
|
|
||||||
|
**Обязательные для статуса "проверено":**
|
||||||
|
- Артист
|
||||||
|
- Номер трека
|
||||||
|
- Название песни
|
||||||
|
- Альбом
|
||||||
|
- Год
|
||||||
|
|
||||||
|
**Для image+.cue:**
|
||||||
|
- REM DATE
|
||||||
|
- TITLE
|
||||||
|
- PERFORMER
|
||||||
|
- TRACK
|
||||||
|
|
||||||
|
Рекомендуемая кодировка: **UTF-8**
|
||||||
|
|
||||||
|
### 3.6 Соответствие треклисту
|
||||||
|
❌ "Track01", "Дорожка 1", "Unknown Artist"
|
||||||
|
✅ Исключение: промо-диски и миксы без треклиста
|
||||||
|
|
||||||
|
### 3.7 Транслитерация
|
||||||
|
🚫 Запрещена для кириллицы
|
||||||
|
✅ Допускается для иероглифов
|
||||||
|
|
||||||
|
### 3.8 Длина пути
|
||||||
|
Рекомендуется лаконичное именование (~255 символов ограничение ОС).
|
||||||
|
|
||||||
|
**Для image+.cue:**
|
||||||
|
```
|
||||||
|
CD.flac, CD.cue, CD.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Для tracks:**
|
||||||
|
```
|
||||||
|
02. What Have I Done ... Pt. 1.flac
|
||||||
|
```
|
||||||
|
(полное название в теге)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Требования к оформлению
|
||||||
|
|
||||||
|
### 4.1 Шаблон
|
||||||
|
Строго заполнять обязательные поля.
|
||||||
|
|
||||||
|
### 4.2 Форматирование
|
||||||
|
🚫 Нижние подчёркивания, точки (кроме пунктуации)
|
||||||
|
🚫 CAPS LOCK (кроме оригинального названия)
|
||||||
|
🚫 Нестандартные шрифты в технических полях
|
||||||
|
|
||||||
|
**Технические данные:**
|
||||||
|
```bbcode
|
||||||
|
[spoiler="Название"][pre]Текст[/pre][/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Заголовок темы
|
||||||
|
Точное название на языке оригинала.
|
||||||
|
Дублировать латиницей при умляутах/диакритике.
|
||||||
|
|
||||||
|
### 4.4 Содержание заголовка
|
||||||
|
|
||||||
|
**Единичный релиз:**
|
||||||
|
- Исполнитель
|
||||||
|
- Год
|
||||||
|
- Название
|
||||||
|
- Формат
|
||||||
|
- Битрейт
|
||||||
|
|
||||||
|
**Сборная раздача:**
|
||||||
|
- Исполнитель
|
||||||
|
- Количество релизов
|
||||||
|
- Годы (1999-2010)
|
||||||
|
- Форматы (через запятую)
|
||||||
|
- Битрейт (диапазон: 128-320 kbps)
|
||||||
|
|
||||||
|
### 4.5 Треклист
|
||||||
|
**Обязателен.** Должен соответствовать содержанию.
|
||||||
|
|
||||||
|
### 4.6 Формат треклиста
|
||||||
|
🚫 Скриншоты
|
||||||
|
🚫 Содержание CUE-файлов
|
||||||
|
🚫 Обратная сторона обложки
|
||||||
|
|
||||||
|
### 4.7 Несколько релизов
|
||||||
|
Каждый под отдельный спойлер с:
|
||||||
|
- Техническими данными
|
||||||
|
- Треклистом
|
||||||
|
- Обложкой
|
||||||
|
|
||||||
|
### 4.8 Транслитерация треклиста
|
||||||
|
🚫 Для кириллицы
|
||||||
|
✅ Для иероглифов
|
||||||
|
|
||||||
|
### 4.9 Обложка
|
||||||
|
|
||||||
|
**Обязательна** (кроме релизов без обложки).
|
||||||
|
|
||||||
|
**Требования:**
|
||||||
|
- Размер: 200x200 - 600x600 px (макс. площадь 360000)
|
||||||
|
- Файл: до 600 KB
|
||||||
|
- 🚫 Анимация
|
||||||
|
- 🚫 Реклама
|
||||||
|
- Дополнительные обложки под спойлер
|
||||||
|
|
||||||
|
### 4.10 Хостинг изображений
|
||||||
|
Без регистрации и паролей.
|
||||||
|
Скриншоты спектров - масштабные превью.
|
||||||
|
|
||||||
|
### 4.11 Дополнительная информация
|
||||||
|
Более 8-10 строк - под спойлер.
|
||||||
|
|
||||||
|
### 4.12 Продолжительность
|
||||||
|
**Обязательно указывать** для каждого релиза.
|
||||||
|
|
||||||
|
### 4.13 Заголовок
|
||||||
|
🚫 "Обновлено 01.01.2022"
|
||||||
|
|
||||||
|
### 4.14 Денежные переводы
|
||||||
|
🚫 Номера счетов и кошельков.
|
||||||
|
|
||||||
|
### 4.15 Ссылки
|
||||||
|
|
||||||
|
🚫 Сторонние ресурсы
|
||||||
|
|
||||||
|
✅ Исключения:
|
||||||
|
- Официальный сайт исполнителя
|
||||||
|
- Официальный сайт лейбла
|
||||||
|
- Discogs и каталогизаторы
|
||||||
|
- Wikipedia
|
||||||
|
- YouTube
|
||||||
|
- WEB-магазины для WEB-релизов
|
||||||
|
|
||||||
|
### 4.16 Перезалив торрента
|
||||||
|
Обязательно сообщение о причинах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
- **24.11.2023** - пп. 3.4.1 и 4.9
|
||||||
|
- **03.01.2024** - п. 2.3
|
||||||
|
- **17.06.2024** - п. 3.7
|
||||||
|
- **17.01.2025** - пп. 2.3 и 4.2
|
||||||
|
- **08.12.2025** - п. 4.12
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Стандартный заголовок
|
||||||
|
^(?:\([^)]+\)\s*)?(.*?)\s*-\s*(.*?)\s*-\s*(\d{4})
|
||||||
|
|
||||||
|
# Формат с годом
|
||||||
|
(\d{4})\s*-\s*(\d{4})|(\d{4})
|
||||||
|
|
||||||
|
# Битрейт
|
||||||
|
(\d+)\s*kbps|V[012]|lossless
|
||||||
|
|
||||||
|
# Формат
|
||||||
|
FLAC|APE|MP3|AAC|OGG|WV
|
||||||
|
|
||||||
|
# Тип рипа
|
||||||
|
image\+\.?cue|tracks\+\.?cue|tracks
|
||||||
|
```
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# RuTracker Hi-Res Audio Upload Rules
|
||||||
|
|
||||||
|
**Source:** https://rutracker.org/forum/viewtopic.php?t=5093969
|
||||||
|
|
||||||
|
## 1. Допустимые форматы Hi-Res (Section 2.1.1)
|
||||||
|
|
||||||
|
### Стереорелизы из исходников высокого разрешения Hi-Res:
|
||||||
|
|
||||||
|
**PCM форматы:**
|
||||||
|
- **24(32)/192** (24 или 32 бит / 192 кГц)
|
||||||
|
- **24(32)/176.4** (24 или 32 бит / 176.4 кГц)
|
||||||
|
- **24(32)/96** (24 или 32 бит / 96 кГц)
|
||||||
|
- **24(32)/88.2** (24 или 32 бит / 88.2 кГц)
|
||||||
|
- **24(32)/48** (24 или 32 бит / 48 кГц)
|
||||||
|
- **24(32)/44.1** (не относится к Hi-Res, но допускается)
|
||||||
|
- **16/48** (не относится к Hi-Res, но допускается)
|
||||||
|
|
||||||
|
**DSD форматы (однобитные):**
|
||||||
|
- **DSD64** (1 bit / 2.8224 MHz) - базовый формат SACD
|
||||||
|
- **DSD128** (1 bit / 5.6 MHz) - в 128 раз выше 44.1 кГц
|
||||||
|
- **DSD256** (DSD265 в документе)
|
||||||
|
- **DSD512**
|
||||||
|
|
||||||
|
## 2. Правила оформления заголовка (Section 3.3-3.5)
|
||||||
|
|
||||||
|
### Для официальных Hi-Res релизов:
|
||||||
|
|
||||||
|
**Формат заголовка темы:**
|
||||||
|
```
|
||||||
|
[TR24][OF] Исполнитель - Название Альбома - Год (Жанр)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Теги:**
|
||||||
|
- `[TR24]` - стереотреки повышенного и высокого разрешения (24 бит и выше)
|
||||||
|
- `[OF]` - официальный релиз (official)
|
||||||
|
|
||||||
|
### Для оцифровок с аналоговых носителей (винил, магнитные ленты):
|
||||||
|
|
||||||
|
**Формат заголовка:**
|
||||||
|
```
|
||||||
|
(Жанр) [Источник] [Формат] Исполнитель - Название Альбома - Год, Кодек (тип рипа)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
```
|
||||||
|
(Hard Rock)[LP][24/96] Whitesnake - Slip Of The Tongue - 1989, WavPack (image+.cue)
|
||||||
|
(Pop) [LP] [24/96] Hi-Fi - Лучшее (2 LP) - 2015, FLAC (image+.cue)
|
||||||
|
(Score/Soundtrack) [LP] [24/96] Matt Uelmen 2022 "Diablo II: Resurrected", FLAC (image+.cue)
|
||||||
|
(Rock Opera) [LP] [24/192] Various (Shiki Theatrical Company) – Jesus Christ Superstar (in Japanese) - 1976, FLAC (image+.cue)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Теги источников (Source Tags)
|
||||||
|
|
||||||
|
- `[LP]` - винил, пластинка диаметром 12 дюймов, записанная на 33-й скорости
|
||||||
|
- `[EP]` - Extended Play (мини-альбом)
|
||||||
|
- `[7"]` - сингл 7 дюймов
|
||||||
|
- `[2LP]` - двойной винил
|
||||||
|
- `[LP MONO]` - моно запись с винила
|
||||||
|
- `[DVDA]` - DVD-Audio формат
|
||||||
|
- `[SACD]` - Super Audio CD
|
||||||
|
- `[DSD]` - Direct Stream Digital формат
|
||||||
|
- `[HDAD]` - High Definition Audio Disc (DVDA с 24/192 или 24/176.4)
|
||||||
|
|
||||||
|
## 4. Формат Bit Depth / Sample Rate
|
||||||
|
|
||||||
|
**Обозначение в квадратных скобках:**
|
||||||
|
- `[24/96]` - 24 бит / 96 кГц
|
||||||
|
- `[24/192]` - 24 бит / 192 кГц
|
||||||
|
- `[24/48]` - 24 бит / 48 кГц
|
||||||
|
- `[24/88.2]` - 24 бит / 88.2 кГц
|
||||||
|
- `[24/176.4]` - 24 бит / 176.4 кГц
|
||||||
|
- `[16/48]` - 16 бит / 48 кГц
|
||||||
|
|
||||||
|
**Формат: [бит/кГц]**
|
||||||
|
|
||||||
|
## 5. DSD Notation (Section 2.1.1, 2.4.3)
|
||||||
|
|
||||||
|
**Форматы DSD:**
|
||||||
|
```
|
||||||
|
DSD64 (1 bit / 2.8224 MHz) - базовый SACD формат
|
||||||
|
DSD128 (1 bit / 5.6 MHz) - удвоенная частота
|
||||||
|
DSD256 (1 bit / 11.2 MHz)
|
||||||
|
DSD512 (1 bit / 22.4 MHz)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Альтернативная запись:**
|
||||||
|
- `DST64` / `DST128` - lossless сжатие DSD (формат DST - MPEG-4 audio)
|
||||||
|
|
||||||
|
## 6. Название папки в раздаче (Section 3.4)
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
```
|
||||||
|
Имя Исполнителя - Название Альбома - (Год - Разрядность-Частоту)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
```
|
||||||
|
Whitesnake - Slip Of The Tongue - (1989 - 24-96)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Обязательные требования к оформлению
|
||||||
|
|
||||||
|
### Dynamic Range Meter (DR meter) - Section 3.7:
|
||||||
|
|
||||||
|
Обязательно публиковать лог DR под спойлером:
|
||||||
|
```
|
||||||
|
[spoiler="Динамический диапазон (DR)"][pre]
|
||||||
|
Содержимое лога DR
|
||||||
|
[/pre][/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Спектры и графики (для оцифровок) - Section 3.3:
|
||||||
|
|
||||||
|
Обязательно показать:
|
||||||
|
- Не менее одного изображения спектра
|
||||||
|
- АЧХ (амплитудно-частотная характеристика)
|
||||||
|
- Уровня записи
|
||||||
|
|
||||||
|
## 8. Запрещенные форматы (Section 2.2)
|
||||||
|
|
||||||
|
1. **WAV-файлы (PCM Wave), не упакованные lossless-кодеками**
|
||||||
|
- Степень сжатия FLAC должна быть не менее 5 (рекомендуется 8)
|
||||||
|
- Для DSD форматом lossless-сжатия является DST (.dff) или WavPack (.wv)
|
||||||
|
|
||||||
|
2. **Релизы с разной частотой семплирования** (Section 2.5.1)
|
||||||
|
|
||||||
|
3. **Апконверты** (Section 5.2)
|
||||||
|
- Апконверты 16 > 24 бит и 44.1 > 48 кГц получают статус "# сомнительно"
|
||||||
|
|
||||||
|
## 9. Дополнительные теги
|
||||||
|
|
||||||
|
- `[RM]` - материал подвергался обработке (Remastered)
|
||||||
|
- `[restored]` - исправление технических недостатков
|
||||||
|
- `[declipped]` - удаление клиппинга
|
||||||
|
|
||||||
|
## Parser Patterns
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Official Hi-Res
|
||||||
|
\[TR24\]\[OF\]
|
||||||
|
|
||||||
|
# Vinyl/Analog Digitizations
|
||||||
|
\[LP\]\s*\[24/\d+\]
|
||||||
|
\[EP\]\s*\[24/\d+\]
|
||||||
|
|
||||||
|
# Bit depth / Sample rate
|
||||||
|
\[24/96\]
|
||||||
|
\[24/192\]
|
||||||
|
\[24/48\]
|
||||||
|
\[24/88\.2\]
|
||||||
|
\[24/176\.4\]
|
||||||
|
|
||||||
|
# DSD formats
|
||||||
|
DSD64|DSD128|DSD256|DSD512
|
||||||
|
DST64|DST128
|
||||||
|
\[DSD\]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional References
|
||||||
|
|
||||||
|
- Теги для оцифровок: https://rutracker.org/forum/viewtopic.php?t=2672956
|
||||||
|
- Правила оцифровок: https://rutracker.org/forum/viewtopic.php?t=3558517
|
||||||
|
- Общие правила музыкальных категорий: https://rutracker.org/forum/viewtopic.php?t=6372776
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Интерактивное содержание правил музыкальных категорий RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=2973838
|
||||||
|
**Актуально на:** 11.06.2023
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Общие правила
|
||||||
|
|
||||||
|
| Документ | Topic ID | Локальный файл |
|
||||||
|
|----------|----------|----------------|
|
||||||
|
| Общие правила публикации и оформления раздач | t=6372776 | [general.md](general.md) |
|
||||||
|
| Правила для lossless подразделов | t=6372775 | [lossless.md](lossless.md) |
|
||||||
|
| Правила для lossy (MP3) подразделов | t=6372774 | [lossy.md](lossy.md) |
|
||||||
|
| Правила оцифровок с аналоговых носителей | t=3558517 | [vinyl-digitization.md](vinyl-digitization.md) |
|
||||||
|
| Правила для Hi-Res музыки | t=5093969 | [hi-res.md](hi-res.md) |
|
||||||
|
| Правила оформления Лейбл-Паков | t=3746663 | [label-packs.md](label-packs.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Специальные правила
|
||||||
|
|
||||||
|
| Документ | Topic ID | Локальный файл |
|
||||||
|
|----------|----------|----------------|
|
||||||
|
| Разъяснения по дискографиям и коллекциям | t=6372771 | [discography.md](discography.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Локальные правила по жанрам
|
||||||
|
|
||||||
|
| Раздел | Topic ID | Локальный файл |
|
||||||
|
|--------|----------|----------------|
|
||||||
|
| Классическая музыка | t=773016 | [classical.md](classical.md) |
|
||||||
|
| Саундтреки | t=2090617, t=2044619 | [soundtracks.md](soundtracks.md) |
|
||||||
|
| Джаз | t=3510475 | [jazz.md](jazz.md) |
|
||||||
|
| Зарубежный Metal | t=4481510 | [metal.md](metal.md) |
|
||||||
|
| Неофициальные сборники (lossless) | t=5555404 | - |
|
||||||
|
| Неофициальные сборники (lossy) | t=2661504 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура общих правил (t=6372776)
|
||||||
|
|
||||||
|
### 1. Введение
|
||||||
|
- 1.1 О музыкальных разделах трекера
|
||||||
|
- 1.2 На какие разделы распространяются правила
|
||||||
|
- 1.3 Таблица публикации по форматам
|
||||||
|
- 1.4 О дополнительных файлах
|
||||||
|
|
||||||
|
### 2. Требования к качеству
|
||||||
|
- 2.1 Источники музыки и форматы
|
||||||
|
- 2.2 Проверка на повтор
|
||||||
|
- 2.3 Запрет апконвертов
|
||||||
|
- 2.4 Запрет автоматического ремастеринга
|
||||||
|
- 2.5 Запрет смешения lossless и lossy
|
||||||
|
- 2.6 Запрет смешения Red Book и Hi-Res
|
||||||
|
- 2.7 Запрет сборников разных жанров
|
||||||
|
- 2.8 Запрет дискографий (с исключениями)
|
||||||
|
|
||||||
|
### 3. Требования к файлам
|
||||||
|
- 3.1 Запрет data-дорожек
|
||||||
|
- 3.2 Запрет дефектных файлов
|
||||||
|
- 3.3 Требования к названию папки
|
||||||
|
- 3.4 Требования к названию файлов
|
||||||
|
- 3.5 Обязательные теги
|
||||||
|
- 3.6 Запрет Track01/Unknown Artist
|
||||||
|
- 3.7 Запрет транслитерации
|
||||||
|
- 3.8 Требования к году и обложке
|
||||||
|
|
||||||
|
### 4. Требования к оформлению
|
||||||
|
- 4.1 Заполнение шаблона
|
||||||
|
- 4.2 Запрет CAPS LOCK
|
||||||
|
- 4.3 Язык названия
|
||||||
|
- 4.4 Содержание заголовка
|
||||||
|
- 4.5 Указание жанра
|
||||||
|
- 4.6 Транслитерация
|
||||||
|
- 4.7 Многодисковые издания
|
||||||
|
- 4.8 Соответствие жанру
|
||||||
|
- 4.9 Требования к обложке
|
||||||
|
- 4.10-4.16 Дополнительные требования
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура правил lossless (t=6372775)
|
||||||
|
|
||||||
|
### 2. Требования к материалу
|
||||||
|
- 2.1 Допустимые форматы (Red Book 16/44.1)
|
||||||
|
- 2.2 Запрет несжатых WAV
|
||||||
|
- 2.3 Рекомендуемые программы (EAC, XLD)
|
||||||
|
- 2.4 Требования к логам
|
||||||
|
- 2.5 Типы рипов (image+cue, tracks+cue)
|
||||||
|
- 2.6 Enhanced CD требования
|
||||||
|
|
||||||
|
### 3. Оформление
|
||||||
|
- 3.1 Публикация LOG
|
||||||
|
- 3.2 Отчёт DR
|
||||||
|
- 3.3 Указание источника
|
||||||
|
- 3.4 Публикация CUE
|
||||||
|
- 3.5 ISO-контейнеры
|
||||||
|
- 3.6 Запрет замены логов ссылками
|
||||||
|
|
||||||
|
### 4. WEB-релизы
|
||||||
|
- 4.1 Определение
|
||||||
|
- 4.2 Обязательные требования
|
||||||
|
- 4.3 Поглощение CD-рипами
|
||||||
|
|
||||||
|
### 5. AI-музыка
|
||||||
|
- 5.1 Определение
|
||||||
|
- 5.2 Требования (тег [AI], ссылка, DR, "as is")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура правил Hi-Res (t=5093969)
|
||||||
|
|
||||||
|
### 2. Требования к материалу
|
||||||
|
- 2.1 Допустимые форматы Hi-Res
|
||||||
|
- 2.1.1 PCM: 24/48 - 24/192
|
||||||
|
- 2.1.2 WEB-релизы
|
||||||
|
- 2.1.3 DVD с LPCM/DSD
|
||||||
|
- 2.1.4 Многоканальные: SACD, Blu-Ray, DVD-Audio
|
||||||
|
- 2.2 Запрещённые форматы
|
||||||
|
- 2.3 Рекомендации по ISO
|
||||||
|
- 2.4 DTS и извлечённые треки
|
||||||
|
- 2.5 Не считаются повтором
|
||||||
|
|
||||||
|
### 3. Оформление
|
||||||
|
- 3.1-3.2 Шаблон
|
||||||
|
- 3.3 Теги в заголовке ([TR24], [OF], etc.)
|
||||||
|
- 3.4 Название папки
|
||||||
|
- 3.5 Название файлов
|
||||||
|
- 3.6 Ссылка на магазин
|
||||||
|
- 3.7 DR meter
|
||||||
|
- 3.8 Техническая информация
|
||||||
|
|
||||||
|
### 4. Программы
|
||||||
|
- 4.1 Для DSD
|
||||||
|
- 4.2 Для остальных форматов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ключевые URL
|
||||||
|
|
||||||
|
```
|
||||||
|
# Базовый URL
|
||||||
|
https://rutracker.org/forum/viewtopic.php?t=
|
||||||
|
|
||||||
|
# Основные правила
|
||||||
|
6372776 - Общие правила
|
||||||
|
6372775 - Lossless
|
||||||
|
6372774 - Lossy
|
||||||
|
3558517 - Оцифровки
|
||||||
|
5093969 - Hi-Res
|
||||||
|
|
||||||
|
# Специальные
|
||||||
|
6372772 - Статус "Сомнительно"
|
||||||
|
6372771 - Дискографии
|
||||||
|
3746663 - Лейбл-паки
|
||||||
|
|
||||||
|
# Жанровые
|
||||||
|
773016 - Классика
|
||||||
|
3510475 - Джаз (стили)
|
||||||
|
4481510 - Metal (lossless)
|
||||||
|
2090617 - Саундтреки (жанры)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Скачанные документы
|
||||||
|
|
||||||
|
1. ✅ [general.md](general.md) - Общие правила
|
||||||
|
2. ✅ [lossless.md](lossless.md) - Lossless правила
|
||||||
|
3. ✅ [lossy.md](lossy.md) - Lossy правила
|
||||||
|
4. ✅ [vinyl-digitization.md](vinyl-digitization.md) - Оцифровки
|
||||||
|
5. ✅ [hi-res.md](hi-res.md) - Hi-Res правила
|
||||||
|
6. ✅ [label-packs.md](label-packs.md) - Лейбл-паки
|
||||||
|
7. ✅ [discography.md](discography.md) - Дискографии
|
||||||
|
8. ✅ [classical.md](classical.md) - Классика
|
||||||
|
9. ✅ [soundtracks.md](soundtracks.md) - Саундтреки
|
||||||
|
10. ✅ [jazz.md](jazz.md) - Джаз
|
||||||
|
11. ✅ [metal.md](metal.md) - Metal
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# Правила оформления раздач в разделе ДЖАЗА RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=1412059
|
||||||
|
**Дополнительные источники:**
|
||||||
|
- t=1080830 (Требования к раздачам в разделе Джаз и Блюз)
|
||||||
|
- t=3510475 (Требования по использованию тэгов стилей)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Что можно размещать
|
||||||
|
|
||||||
|
### 1.1 Обязательно использовать поиск
|
||||||
|
Проверить дубликаты по исполнителю и названию.
|
||||||
|
|
||||||
|
### 1.2 Названия
|
||||||
|
На оригинальном языке, без перевода.
|
||||||
|
Для нелатинских языков - транскрипция в скобках.
|
||||||
|
|
||||||
|
### 1.3 Сборники
|
||||||
|
|
||||||
|
**Запрещены неофициальные сборники:**
|
||||||
|
- "Все песни артиста"
|
||||||
|
- "Все хиты за N-ный год"
|
||||||
|
- "Танцевальная музыка года"
|
||||||
|
|
||||||
|
**Требования к сборникам:**
|
||||||
|
- Размер: до 700 МБ (lossy)
|
||||||
|
- Треков: до 200
|
||||||
|
|
||||||
|
**Разрешены:**
|
||||||
|
- Официальные компиляции
|
||||||
|
- Тематические сборники жанра
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Технические требования
|
||||||
|
|
||||||
|
### 2.1 Форматы Lossless
|
||||||
|
|
||||||
|
**Запрещены:**
|
||||||
|
- Несжатые образы: WAV, NRG, MDF/MDS
|
||||||
|
- Архивы: RAR, ZIP
|
||||||
|
- Исполняемые файлы: EXE, COM
|
||||||
|
|
||||||
|
**Рекомендуемые кодеки:**
|
||||||
|
- FLAC
|
||||||
|
- APE
|
||||||
|
- WavPack
|
||||||
|
|
||||||
|
### 2.2 Типы рипов
|
||||||
|
|
||||||
|
1. **Image+CUE+LOG** - полный образ
|
||||||
|
2. **Tracks+Non-compliant CUE+LOG** - потреково
|
||||||
|
|
||||||
|
### 2.3 LOG-файл
|
||||||
|
|
||||||
|
Создавать **EAC** (Exact Audio Copy).
|
||||||
|
|
||||||
|
```bbcode
|
||||||
|
[spoiler="Отчет EAC"]
|
||||||
|
Содержимое лог-файла
|
||||||
|
[/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Если источник неизвестен:**
|
||||||
|
```
|
||||||
|
Рип сделан: неизвестно
|
||||||
|
Источник: название источника
|
||||||
|
```
|
||||||
|
+ скриншот спектра Tau Analyzer или Adobe Audition.
|
||||||
|
|
||||||
|
### 2.4 Запрещённые режимы
|
||||||
|
|
||||||
|
🚫 Burst (скоростной)
|
||||||
|
🚫 Paranoid (параноидальный)
|
||||||
|
🚫 Normalize (нормализация)
|
||||||
|
|
||||||
|
✅ Только **Secure** (безопасный)
|
||||||
|
|
||||||
|
### 2.5 Апконверты
|
||||||
|
|
||||||
|
3 апконвертированных трека = повтор.
|
||||||
|
Проверять через **Tau Analyzer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Оформление раздачи
|
||||||
|
|
||||||
|
### 3.1 Запреты в названии
|
||||||
|
|
||||||
|
🚫 Только ВЕРХНИЙ РЕГИСТР
|
||||||
|
🚫 Точки (.) кроме пунктуации
|
||||||
|
🚫 Нижние подчёркивания (_) вместо пробелов
|
||||||
|
🚫 Жанр в названии (указывается в поле шаблона)
|
||||||
|
|
||||||
|
### 3.2 Обложка
|
||||||
|
|
||||||
|
- Размер: **500×500 px** (рекомендуется)
|
||||||
|
- Минимум: **200×200 px**
|
||||||
|
- 🚫 Реклама сторонних ресурсов
|
||||||
|
- Сканирование: 300-600 dpi
|
||||||
|
|
||||||
|
### 3.3 Лейбл
|
||||||
|
|
||||||
|
По-английски или транслитерация:
|
||||||
|
- `Blue Note`
|
||||||
|
- `Melodiya` или `Мелодия`
|
||||||
|
|
||||||
|
### 3.4 Информация о записи
|
||||||
|
|
||||||
|
**Крайне желательно для джаза:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Recorded: December 9, 1965
|
||||||
|
Studio: Van Gelder Studio, Englewood Cliffs, NJ
|
||||||
|
|
||||||
|
Personnel:
|
||||||
|
John Coltrane - tenor saxophone
|
||||||
|
McCoy Tyner - piano
|
||||||
|
Jimmy Garrison - bass
|
||||||
|
Elvin Jones - drums
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Тэги стилей (обязательно!)
|
||||||
|
|
||||||
|
**"Jazz" недостаточно** - необходимо указывать конкретный стиль.
|
||||||
|
|
||||||
|
### Early Jazz, Swing, Gypsy
|
||||||
|
- Boogie-Woogie
|
||||||
|
- Ragtime
|
||||||
|
- Dixieland
|
||||||
|
- Classic Jazz
|
||||||
|
- New Orleans Jazz
|
||||||
|
- Big Band
|
||||||
|
- Swing
|
||||||
|
- Gypsy
|
||||||
|
|
||||||
|
### Bop
|
||||||
|
- Bop
|
||||||
|
- Hard Bop
|
||||||
|
- Post-Bop
|
||||||
|
- Neo-Bop
|
||||||
|
- Modal Music
|
||||||
|
- Modern Big Band
|
||||||
|
|
||||||
|
### Mainstream Jazz, Cool
|
||||||
|
- Mainstream Jazz
|
||||||
|
- Cool
|
||||||
|
- West Coast Jazz
|
||||||
|
- Soul-Jazz
|
||||||
|
- Standards
|
||||||
|
|
||||||
|
### Jazz Fusion
|
||||||
|
- Fusion
|
||||||
|
- Jazz-Rock
|
||||||
|
- Jazz-Funk
|
||||||
|
- Jazzy Blues
|
||||||
|
|
||||||
|
### World Fusion, Ethnic Jazz
|
||||||
|
- World Fusion
|
||||||
|
- Latin Jazz
|
||||||
|
- Bossa Nova
|
||||||
|
- Afro-Cuban Jazz
|
||||||
|
- Brazilian Jazz
|
||||||
|
- Tango Nuevo
|
||||||
|
|
||||||
|
### Avant-Garde Jazz, Free Improvisation
|
||||||
|
- Avant-Garde Jazz
|
||||||
|
- Free Jazz
|
||||||
|
- Free Funk
|
||||||
|
- Experimental Jazz
|
||||||
|
- M-Base
|
||||||
|
|
||||||
|
### Modern Creative, Third Stream
|
||||||
|
- Modern Creative
|
||||||
|
- Third Stream
|
||||||
|
- Chamber Jazz
|
||||||
|
- Progressive Jazz
|
||||||
|
|
||||||
|
### Smooth, Jazz-Pop
|
||||||
|
- Smooth Jazz
|
||||||
|
- Crossover Jazz
|
||||||
|
- Jazz-Pop
|
||||||
|
- Easy Listening
|
||||||
|
|
||||||
|
### Vocal Jazz
|
||||||
|
- Vocal Jazz
|
||||||
|
- Acapella
|
||||||
|
|
||||||
|
### Funk, Soul, R&B
|
||||||
|
- Funk
|
||||||
|
- Soul
|
||||||
|
- R&B
|
||||||
|
- Gospel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Треклист
|
||||||
|
|
||||||
|
### 5.1 Обязательное содержание
|
||||||
|
- Порядковый номер
|
||||||
|
- Название
|
||||||
|
- Продолжительность (желательно)
|
||||||
|
|
||||||
|
Для сборников - исполнитель для каждого трека.
|
||||||
|
|
||||||
|
### 5.2 50+ треков - под спойлер
|
||||||
|
|
||||||
|
```bbcode
|
||||||
|
[spoiler="Треклист"]
|
||||||
|
01. Track Name (5:23)
|
||||||
|
...
|
||||||
|
[/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Битрейт
|
||||||
|
|
||||||
|
Если разный - указывать для каждого трека.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Название папки
|
||||||
|
|
||||||
|
```
|
||||||
|
Artist Name - Album Title (Year) [Format]
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
```
|
||||||
|
John Coltrane - A Love Supreme (1965) [FLAC]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Источник
|
||||||
|
|
||||||
|
```
|
||||||
|
Источник: What.CD
|
||||||
|
Релизёр: username
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Джаз стили
|
||||||
|
(?:Hard |Post-|Neo-)?Bop|Swing|Dixieland|Big\s*Band|Fusion|Cool|Latin\s*Jazz|Bossa\s*Nova|Free\s*Jazz|Smooth\s*Jazz|Soul[\s-]?Jazz|Avant[\s-]?Garde
|
||||||
|
|
||||||
|
# Personnel формат
|
||||||
|
Personnel:\s*\n((?:[^\n]+\s*-\s*[^\n]+\n?)+)
|
||||||
|
|
||||||
|
# Recorded дата
|
||||||
|
Recorded:\s*([A-Za-z]+\s+\d+,?\s+\d{4})
|
||||||
|
|
||||||
|
# Studio/Location
|
||||||
|
(?:Studio|Location):\s*([^\n]+)
|
||||||
|
|
||||||
|
# Название папки
|
||||||
|
([^-]+)\s*-\s*([^(]+)\s*\((\d{4})\)\s*\[([A-Z]+)\]
|
||||||
|
```
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Правила оформления раздач Лейбл Паков (Label Pack)
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=3746663
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Важное примечание
|
||||||
|
|
||||||
|
**Создавать раздачу необходимо в жанровом подразделе, в раздел Лейбл Паков она будет перенесена модератором после проверки**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Перед созданием раздачи вы должны:
|
||||||
|
|
||||||
|
**1.** Проверить по поиску, не существует ли аналогичная раздача на трекере
|
||||||
|
|
||||||
|
[Как пользоваться Поиском?](https://rutracker.org/forum/viewtopic.php?t=101236)
|
||||||
|
|
||||||
|
**2.** Ознакомиться с правилами оформления раздач в разделе Музыка
|
||||||
|
|
||||||
|
- [Общие правила оформления раздач в разделе Музыка](https://rutracker.org/forum/viewtopic.php?t=6372776)
|
||||||
|
- [Правила публикации и оформления раздач в lossless разделах категорий Музыка, Рок-музыка, Электронная музыка](https://rutracker.org/forum/viewtopic.php?t=6372775)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что такое Лейбл Пак?
|
||||||
|
|
||||||
|
**Лейбл Пак (Label Pack)** - это сборная раздача, включающая в себя релизы музыкантов, изданные одним лейблом и его суб-лейблами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Оформление заголовка
|
||||||
|
|
||||||
|
Заголовок - это краткая информация по всей вашей раздаче, поэтому заголовок темы должен быть максимально информативным.
|
||||||
|
|
||||||
|
### Формат заголовка:
|
||||||
|
|
||||||
|
```
|
||||||
|
(Жанр) Label: Название лейбла (количество релизов), - диапазон лет (Аудиокодек), битрейт (тип рипа)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Компоненты заголовка:
|
||||||
|
|
||||||
|
- **(Жанр)** - в скобках указывается жанр, обычно сюда пишется 3 жанра, которые являются преимущественными для вашей раздачи
|
||||||
|
|
||||||
|
- **Название лейбла** - Указание на то, что раздача принадлежит к числу Лейбл паков
|
||||||
|
- Например: `Label: Acroplane Recordings`
|
||||||
|
|
||||||
|
- **(количество релизов)** - укажите общее количество альбомов, которые содержит ваша раздача
|
||||||
|
|
||||||
|
- **диапазон лет, за период которые собраны релизы**
|
||||||
|
- Пример: `2000-2010 г.`
|
||||||
|
|
||||||
|
- **Аудиокодек** - Кодек, которым сжаты ваши аудиофайлы, самый распространённый - это `.flac` (`*FLAC`). Если в раздаче треки сжаты несколькими кодеками, то их нужно указать через запятую
|
||||||
|
|
||||||
|
- **битрейт** - качество релизов в вашей раздаче (для lossless достаточно так и указать - `lossless`)
|
||||||
|
|
||||||
|
- **Тип рипа** - потрековый или образом, если типов рипа несколько, то они указываются через запятую
|
||||||
|
|
||||||
|
### Пример оформленного заголовка:
|
||||||
|
|
||||||
|
```
|
||||||
|
(IDM, Experimental, Ambient) Label: 33 Recordings (55 releases), 2008 - 2011, (FLAC) lossless (tracks+.cue)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Создание и оформление раздачи
|
||||||
|
|
||||||
|
### Требования к раздаваемому материалу
|
||||||
|
|
||||||
|
#### Обязательное требование к названию папки
|
||||||
|
|
||||||
|
Каждый релиз должен быть помещён в отдельную папку, в названии которой должны быть указаны:
|
||||||
|
|
||||||
|
```
|
||||||
|
[каталожный номер] Название исполнителя - Название альбома (год)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- Каталожный номер всегда должен быть прописан в начале названия папки
|
||||||
|
- Все папки в раздаче должны быть приведены к одному виду. Если, к примеру, вы решили указывать год в круглых скобках, то это должно быть применено для каждой папки. Это так же касается сцен-релизов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Требования по оформлению раздач
|
||||||
|
|
||||||
|
Каждый альбом в раздаче должен быть представлен под отдельным спойлером, в который должна быть включена следующая информация:
|
||||||
|
|
||||||
|
1. Название исполнителя
|
||||||
|
2. Название альбома
|
||||||
|
3. Год выхода альбома
|
||||||
|
4. Каталожный номер
|
||||||
|
5. Жанр
|
||||||
|
6. Треклист
|
||||||
|
7. Обложка (Если существует)
|
||||||
|
8. Битрейт
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Пример оформления альбома
|
||||||
|
|
||||||
|
```
|
||||||
|
[spoiler="[SEMANTICA 05] Various - Prologue [2008] - CD"]
|
||||||
|
[IMG=right]http://i6.imageban.ru/out/2017/09/01/f829816b7a97df780abb366bfcd93792.jpg[/IMG]
|
||||||
|
[font=mono1][b]Жанр: [/b] Techno, IDM, Dub Techno
|
||||||
|
[b]Продолжительность[/b]:00:58:58
|
||||||
|
|
||||||
|
[b]01.[/b] Julien Neto – Reprise [i](03:58)[/i]
|
||||||
|
[b]02.[/b] Arcanoid – Sad (Talking About) [i](07:02)[/i]
|
||||||
|
[b]03.[/b] Acid Future Overdose – 99926 [i](08:10)[/i]
|
||||||
|
[b]04.[/b] Jimmy Edgar – Warm Play Look Away [i](05:12)[/i]
|
||||||
|
[b]05.[/b] Oscar Mulero – Paris, Texas [i](06:32)[/i]
|
||||||
|
[b]06.[/b] Ed Chamberlain – Does Ape [i](06:07)[/i]
|
||||||
|
[b]07.[/b] Ideograma – Alert S.E.T.I. Home [i](06:19)[/i]
|
||||||
|
[b]08.[/b] Plant43 – Extrasolar [i](04:38)[/i]
|
||||||
|
[b]09.[/b] Annie Hall – Wine & Beats [i](04:50)[/i]
|
||||||
|
[b]10.[/b] Svreca – Eye (DisinVectant Optic Nerve Remix) [i](06:10)[/i]
|
||||||
|
[hr]
|
||||||
|
[url=https://www.discogs.com/Various-Prologue/release/1521766][img]http://i59.fastpic.ru/big/2013/0914/11/277b80b65490a21939363761aa55d111.png[/img][/url]
|
||||||
|
|
||||||
|
[spoiler="Лог создания рипа"][pre]
|
||||||
|
Exact Audio Copy V0.99 prebeta 5 from 4. May 2009
|
||||||
|
EAC extraction logfile from 18. November 2010, 17:14
|
||||||
|
...
|
||||||
|
[/pre][/spoiler]
|
||||||
|
|
||||||
|
[spoiler="Содержание индексной карты (.CUE)"][pre]
|
||||||
|
REM GENRE Electronic
|
||||||
|
REM DATE 2008
|
||||||
|
REM DISCID 880DD10A
|
||||||
|
...
|
||||||
|
[/pre][/spoiler]
|
||||||
|
[/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дополнительные требования
|
||||||
|
|
||||||
|
### Обязательно:
|
||||||
|
|
||||||
|
- **Обязательно** для лейбл паков указывать **порелизовую продолжительность** (продолжительность звучания каждого релиза), так как бывают различные вариации альбомов, когда набор треков одинаков, но длительность бывает различна.
|
||||||
|
|
||||||
|
- **Обязательно** указание **потрековой продолжительности** для веб релизов, входящих в состав раздачи
|
||||||
|
|
||||||
|
- **Обязательно** указание жанровой принадлежности каждого альбома, так как далеко не все лейблы специализируются на каком-то определенном жанре.
|
||||||
|
- **Допускается** не указывать жанровую принадлежность для каждого альбома в случаях, когда она одинакова для всех альбомов в раздаче
|
||||||
|
|
||||||
|
### Приветствуется:
|
||||||
|
|
||||||
|
- **Приветствуется** указание общей продолжительности звучания всех альбомов в раздаче
|
||||||
|
|
||||||
|
- **Приветствуется** указание потрековой продолжительности всех альбомов, входящих в состав раздачи, включая CD и Vinyl рипы
|
||||||
|
|
||||||
|
- **Приветствуется** указание типа носителя, с которого был снят рип: CD, Vinyl, Cassette, WEB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дополнительная информация
|
||||||
|
|
||||||
|
- В случае, если оформление раздачи превышает допустимый лимит символов в сообщении, то вы можете продолжить оформление в следующем сообщении.
|
||||||
|
|
||||||
|
- [Пример оформленной раздачи](https://rutracker.org/forum/viewtopic.php?t=4542920)
|
||||||
|
|
||||||
|
- [Ускорение оформления сборных раздач (дискографий, коллекций) при помощи универсальной программы редактирования тегов - mp3tag](https://rutracker.org/forum/viewtopic.php?t=3024659)
|
||||||
|
|
||||||
|
- **Одиночные раздачи альбомов, входящих в состав Лейбл пака, не поглощаются**
|
||||||
|
|
||||||
|
- **В случае обновления старой раздачи, новая раздача должна полностью соответствовать всем требованиям по оформлению раздач лейбл паков**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Особенности lossless Лейбл паков
|
||||||
|
|
||||||
|
Оформление lossless Лейбл пака принципиально не отличается от оформления lossy лейбл пака, за исключением обязательности публикации:
|
||||||
|
|
||||||
|
- Логов снятия рипа
|
||||||
|
- cue-sheet
|
||||||
|
- Или логов проверки качества (при условии, если лог снятия рипа отсутствует)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Документ создан:** 2026-05-04
|
||||||
|
**Последнее обновление на RuTracker:** 06-июн-20 22:39
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Правила для lossless подразделов RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372775
|
||||||
|
**Версия:** © 2023
|
||||||
|
**Последнее обновление:** 05.11.2025
|
||||||
|
|
||||||
|
## 1. Введение
|
||||||
|
|
||||||
|
Настоящие правила являются дополнительными по отношению к правилам, изложенным в общей части, и описывают специфические требования к публикации и оформлению раздач в lossless-разделах категорий форума Музыка, Популярная музыка, Джазовая и Блюзовая музыка, Рок-музыка, Электронная музыка.
|
||||||
|
|
||||||
|
## 2. Требования к раздаваемому материалу
|
||||||
|
|
||||||
|
### 2.1 Допустимые форматы и источники
|
||||||
|
|
||||||
|
В lossless-подразделах раздаётся исключительно:
|
||||||
|
|
||||||
|
- **Рипы с CD**
|
||||||
|
- **WEB-релизы** с характеристиками **16 бит / 44100 Гц** (стандарт Red Book)
|
||||||
|
|
||||||
|
**Исключение:** Если релиз содержит 50% и более материала, отличного от Red Book, его следует оформлять в Hi-Res подразделах.
|
||||||
|
|
||||||
|
### 2.2 Требования к упаковке
|
||||||
|
|
||||||
|
**Категорически запрещается** раздача WAV-файлов, не упакованных lossless-кодеками.
|
||||||
|
|
||||||
|
**Степень сжатия FLAC:** не менее 5 (рекомендуется 8)
|
||||||
|
|
||||||
|
> ⚠️ Не путать WavPack (*.wv) с PCM Wave (*.wav)
|
||||||
|
|
||||||
|
### 2.3 Запрет на виртуальные приводы
|
||||||
|
|
||||||
|
**Категорически запрещается** публикация рипов с виртуальных приводов.
|
||||||
|
|
||||||
|
### 2.4 Рекомендуемые программы
|
||||||
|
|
||||||
|
- **EAC (Exact Audio Copy)** - для Windows и Linux (Wine)
|
||||||
|
- **XLD (X Lossless Decoder)** - для Mac OS
|
||||||
|
|
||||||
|
Только рипы по официальным инструкциям получают статус "проверено".
|
||||||
|
|
||||||
|
### 2.5 Равнозначные форматы раздач
|
||||||
|
|
||||||
|
Равнозначными считаются:
|
||||||
|
1. Рипы **image+cue+log**
|
||||||
|
2. Рипы **tracks+non-compliant cue+log**
|
||||||
|
|
||||||
|
Потрековая раздача = повтор при наличии образа, и наоборот.
|
||||||
|
|
||||||
|
### 2.6 Равнозначность lossless-кодеков
|
||||||
|
|
||||||
|
Все lossless-кодеки равнозначны (FLAC, APE, WavPack, etc.). Рип в одном кодеке не поглощает рип в другом - это повторы.
|
||||||
|
|
||||||
|
### 2.7 Enhanced CD (Extra CD)
|
||||||
|
|
||||||
|
**Допускается** включать бонусы (видеоклипы, mp3, flash) если они были на оригинальном диске.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
- 2-3 скриншота для каждого видеофайла
|
||||||
|
- Отчёт MediaInfo под спойлером
|
||||||
|
|
||||||
|
## 3. Требования к оформлению
|
||||||
|
|
||||||
|
### 3.1 Публикация лога EAC/XLD
|
||||||
|
|
||||||
|
```bbcode
|
||||||
|
[spoiler="Лог создания рипа"][pre]содержание лог-файла[/pre][/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Отчёт о динамическом диапазоне (DR)
|
||||||
|
|
||||||
|
Обязателен для раздач без лога извлечения (WEB-релизы, авторский материал):
|
||||||
|
|
||||||
|
```bbcode
|
||||||
|
[spoiler="Динамический отчет (DR)"][pre]Содержимое лога[/pre][/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Программы для DR:**
|
||||||
|
- **Windows:** foobar2000 + Dynamic Range Meter plugin
|
||||||
|
- **Linux:** DeaDBeeF + dr-meter plugin
|
||||||
|
- **Mac OS:** foobar2000 + plugin
|
||||||
|
|
||||||
|
### 3.3 Указание источника рипа
|
||||||
|
|
||||||
|
Обязательно указать:
|
||||||
|
- **"Собственный рип"** или **"Сторонний рип"**
|
||||||
|
- Для сторонних: название ресурса (без ссылки), ник релизера
|
||||||
|
|
||||||
|
### 3.4 Публикация CUE Sheet
|
||||||
|
|
||||||
|
Независимо от типа рипа:
|
||||||
|
|
||||||
|
```bbcode
|
||||||
|
[spoiler="Содержание индексной карты (.CUE)"][pre]содержание[/pre][/spoiler]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 ISO-контейнеры
|
||||||
|
|
||||||
|
Для рипов в iso.wv - указать перечень файлов контейнера под отдельным спойлером.
|
||||||
|
|
||||||
|
### 3.6 Запрет замены логов ссылками
|
||||||
|
|
||||||
|
**Не допускается** заменять лог сообщениями типа "лог внутри раздачи".
|
||||||
|
|
||||||
|
## 4. WEB-релизы в lossless
|
||||||
|
|
||||||
|
### 4.1 Определение
|
||||||
|
|
||||||
|
WEB-релиз - материал в lossless через цифровую дистрибуцию:
|
||||||
|
- Интернет-магазины (Beatport, Juno)
|
||||||
|
- Сайты лейблов и исполнителей
|
||||||
|
- Авторские раздачи
|
||||||
|
|
||||||
|
### 4.2 Обязательные требования
|
||||||
|
|
||||||
|
1. **Указание [WEB]** в заголовке
|
||||||
|
2. **Ссылка на источник** (магазин, сайт лейбла)
|
||||||
|
- Если неизвестен: "WEB-магазин: неизвестен"
|
||||||
|
- Если авторская: "WEB-магазин: материал предоставлен авторами"
|
||||||
|
3. **Заполнение поля "Источник"**
|
||||||
|
- Собственная покупка
|
||||||
|
- Авторская раздача
|
||||||
|
- Название ресурса
|
||||||
|
4. **Отчёт DR**
|
||||||
|
|
||||||
|
### 4.3 Поглощение WEB CD-рипами
|
||||||
|
|
||||||
|
WEB-релиз в Red Book может быть поглощён правильным CD-рипом при совпадении мастеринга.
|
||||||
|
|
||||||
|
**Условия совпадения мастеринга:**
|
||||||
|
- Совпадение DR value / Samplerate / Bits per sample / Channels
|
||||||
|
- Погрешность Peak: ±0.2 dB
|
||||||
|
|
||||||
|
## 5. AI-музыка в lossless
|
||||||
|
|
||||||
|
### 5.1 Определение
|
||||||
|
|
||||||
|
AI Generated Music (AIGM) - материалы, сгенерированные с помощью ИИ.
|
||||||
|
|
||||||
|
### 5.2 Требования
|
||||||
|
|
||||||
|
1. **Тег [AI]** в заголовке
|
||||||
|
2. **Ссылка на источник** (кроме собственной генерации)
|
||||||
|
3. **Отчёт DR**
|
||||||
|
4. **Публикация "as is"** - без ремастеринга
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
- **24.11.2023** — добавлен п. 2.7
|
||||||
|
- **17.07.2024** — изменены пп. 3.1, 3.2, 4.2.4, 4.3
|
||||||
|
- **17.01.2025** — удален п. 2.7, добавлен п. 5
|
||||||
|
- **27.02.2025** — изменен п. 5.2.2, добавлен п. 5.2.4
|
||||||
|
- **05.11.2025** — изменен п. 4.3
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# WEB тег
|
||||||
|
\[WEB\]
|
||||||
|
|
||||||
|
# AI тег
|
||||||
|
\[AI\]
|
||||||
|
|
||||||
|
# FLAC степень сжатия
|
||||||
|
FLAC\s*(?:level\s*)?([5-8])
|
||||||
|
|
||||||
|
# Источник рипа
|
||||||
|
(?:Собственный|Сторонний)\s+рип
|
||||||
|
|
||||||
|
# Red Book формат
|
||||||
|
16\s*(?:bit|бит).*44[.,]?1\s*(?:kHz|кГц)
|
||||||
|
```
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Правила для lossy (MP3) подразделов RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=6372774
|
||||||
|
|
||||||
|
## 1. Введение
|
||||||
|
|
||||||
|
Настоящие правила являются дополнительными по отношению к правилам, изложенным в общей части, и описывают специфические требования к публикации и оформления раздач в lossy-разделах категорий форума Музыка, Популярная музыка, Джазовая и Блюзовая музыка, Рок-музыка, Электронная музыка.
|
||||||
|
|
||||||
|
Применимые форматы: MP3, AAC, OggVorbis, Musepack, WMA.
|
||||||
|
|
||||||
|
## 2. Требования к раздаваемому материалу
|
||||||
|
|
||||||
|
### 2.1 Запрещено включать в раздачу:
|
||||||
|
- Видеоклипы
|
||||||
|
- Скринсейверы
|
||||||
|
- Обои
|
||||||
|
- RAW/lossless сканы обложек
|
||||||
|
|
||||||
|
### 2.2 Разрешено включать:
|
||||||
|
- Аудио бонусы
|
||||||
|
- Сканы обложек альбома (JPG)
|
||||||
|
- Пресс-релизы
|
||||||
|
|
||||||
|
### 2.3 Обложки в тегах:
|
||||||
|
- Максимум 1 изображение на трек
|
||||||
|
- Максимум 300 KB на изображение
|
||||||
|
|
||||||
|
## 3. Требования к оформлению
|
||||||
|
|
||||||
|
### 3.1 Обозначение битрейта
|
||||||
|
|
||||||
|
**CBR (постоянный битрейт):**
|
||||||
|
- 320 kbps
|
||||||
|
- 256 kbps
|
||||||
|
- 192 kbps
|
||||||
|
- 128 kbps
|
||||||
|
|
||||||
|
**VBR (переменный битрейт) - пресеты:**
|
||||||
|
- V0 (максимальное качество, ~245 kbps)
|
||||||
|
- V1 (~225 kbps)
|
||||||
|
- V2 (~190 kbps)
|
||||||
|
|
||||||
|
**VBR (переменный битрейт) - диапазон:**
|
||||||
|
- VBR 192-320 kbps
|
||||||
|
- VBR 128-320 kbps
|
||||||
|
|
||||||
|
**VBR (переменный битрейт) - средний:**
|
||||||
|
- VBR ~256 kbps (avg)
|
||||||
|
|
||||||
|
### 3.2 Многоальбомные релизы
|
||||||
|
|
||||||
|
Каждый альбом должен быть под отдельным спойлером с указанием:
|
||||||
|
- Год выпуска
|
||||||
|
- Название альбома
|
||||||
|
- Битрейт
|
||||||
|
|
||||||
|
## 4. Правила AI-музыки (добавлено январь 2025)
|
||||||
|
|
||||||
|
### 4.1 Обязательный тег
|
||||||
|
В заголовке раздачи должен быть тег **[AI]**
|
||||||
|
|
||||||
|
### 4.2 Ссылка на источник
|
||||||
|
Обязательно указать ссылку на страницу покупки (кроме самостоятельно сгенерированного материала)
|
||||||
|
|
||||||
|
### 4.3 Публикация "as is"
|
||||||
|
Материал должен публиковаться без ремастеринга, в исходном виде
|
||||||
|
|
||||||
|
## Шаблоны заголовков
|
||||||
|
|
||||||
|
### Стандартный формат:
|
||||||
|
```
|
||||||
|
(Жанр) Исполнитель - Название Альбома - Год, Формат Битрейт
|
||||||
|
```
|
||||||
|
|
||||||
|
### Примеры:
|
||||||
|
```
|
||||||
|
(Rock) Pink Floyd - The Wall - 1979, MP3 320 kbps
|
||||||
|
(Electronic) Daft Punk - Random Access Memories - 2013, MP3 V0
|
||||||
|
(Pop) Madonna - Like a Prayer - 1989, MP3 VBR 192-320 kbps
|
||||||
|
```
|
||||||
|
|
||||||
|
### С тегом AI:
|
||||||
|
```
|
||||||
|
(Electronic) [AI] AI Artist - Generated Album - 2025, MP3 320 kbps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# CBR битрейт
|
||||||
|
(\d{2,3})\s*kbps
|
||||||
|
|
||||||
|
# VBR пресеты
|
||||||
|
V[012]
|
||||||
|
|
||||||
|
# VBR диапазон
|
||||||
|
VBR\s+(\d+)-(\d+)\s*kbps
|
||||||
|
|
||||||
|
# VBR средний
|
||||||
|
VBR\s+~?(\d+)\s*kbps
|
||||||
|
|
||||||
|
# AI тег
|
||||||
|
\[AI\]
|
||||||
|
```
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Правила оформления раздач в разделе ЗАРУБЕЖНЫЙ METAL RuTracker
|
||||||
|
|
||||||
|
**Источник:** Compiled from RuTracker.org forum rules
|
||||||
|
**Примечание:** Прямой доступ к t=1412060 требует авторизации
|
||||||
|
|
||||||
|
## 1. Формат заголовка
|
||||||
|
|
||||||
|
```
|
||||||
|
(Жанр/Подстиль) Название группы - Название альбома - Год, Аудиокодек, Битрейт
|
||||||
|
```
|
||||||
|
|
||||||
|
### Примеры:
|
||||||
|
```
|
||||||
|
(Heavy/Power/Thrash Metal) Metal Church - Dead to Rights - 2026, MP3, 320 kbps
|
||||||
|
(Black Metal) Darkthrone - A Blaze in the Northern Sky - 1992, FLAC (image+.cue), lossless
|
||||||
|
(Death Metal, Doom Metal) Paradise Lost - Gothic - 1991, APE (tracks), lossless
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Обозначение жанров Metal
|
||||||
|
|
||||||
|
### Основные жанры:
|
||||||
|
- Heavy Metal
|
||||||
|
- Power Metal
|
||||||
|
- Thrash Metal
|
||||||
|
- Speed Metal
|
||||||
|
- Progressive Metal
|
||||||
|
- Death Metal
|
||||||
|
- Black Metal
|
||||||
|
- Doom Metal
|
||||||
|
- Gothic Metal
|
||||||
|
- Symphonic Metal
|
||||||
|
- Folk Metal
|
||||||
|
- Viking Metal
|
||||||
|
- Pagan Metal
|
||||||
|
- Grindcore
|
||||||
|
- Metalcore
|
||||||
|
- Deathcore
|
||||||
|
|
||||||
|
### Формат указания:
|
||||||
|
- Жанры через запятую: `Death Metal, Doom Metal`
|
||||||
|
- Жанры через слэш: `Heavy/Power Metal`
|
||||||
|
- В скобках в начале заголовка
|
||||||
|
|
||||||
|
## 3. Обязательные элементы
|
||||||
|
|
||||||
|
### В заголовке:
|
||||||
|
1. Жанр (в скобках)
|
||||||
|
2. Название группы (оригинальное написание)
|
||||||
|
3. Название альбома
|
||||||
|
4. Год издания
|
||||||
|
5. Аудиокодек (MP3, FLAC, APE)
|
||||||
|
6. Битрейт/тип рипа
|
||||||
|
|
||||||
|
### В описании:
|
||||||
|
1. Жанр
|
||||||
|
2. Страна группы
|
||||||
|
3. Год издания
|
||||||
|
4. Аудиокодек
|
||||||
|
5. Тип рипа (tracks / image+cue)
|
||||||
|
6. Битрейт
|
||||||
|
7. Продолжительность
|
||||||
|
8. Наличие сканов
|
||||||
|
9. Треклист
|
||||||
|
10. Лейбл
|
||||||
|
|
||||||
|
## 4. Требования к тегам
|
||||||
|
|
||||||
|
### Обязательные теги:
|
||||||
|
1. **Название песни** - оригинальное написание
|
||||||
|
2. **Альбом** - с дополнениями (Single, [EP], Live)
|
||||||
|
3. **Исполнитель** - оригинальное написание (W.A.S.P., не WASP)
|
||||||
|
4. **Год** - год альбома, не переиздания
|
||||||
|
5. **Номер трека**
|
||||||
|
6. **Номер диска** (для многодисковых)
|
||||||
|
7. **Жанр**
|
||||||
|
8. **Обложка** (200×200 - 600×600 px)
|
||||||
|
|
||||||
|
## 5. Типы релизов
|
||||||
|
|
||||||
|
### Форматы альбомов:
|
||||||
|
- **Studio Album** - студийный альбом
|
||||||
|
- **Live** - концертная запись
|
||||||
|
- **EP** - мини-альбом
|
||||||
|
- **Single** - сингл
|
||||||
|
- **Compilation** - сборник
|
||||||
|
- **Demo** - демо-запись
|
||||||
|
- **Bootleg** - бутлег
|
||||||
|
- **Remaster** / **Reissue** - ремастер / переиздание
|
||||||
|
- **Deluxe Edition** - делюкс издание
|
||||||
|
- **Box Set** - бокс-сет
|
||||||
|
|
||||||
|
### Специальные издания:
|
||||||
|
- **(Japan Edition)**
|
||||||
|
- **(Limited Edition)**
|
||||||
|
- **(Digipak)**
|
||||||
|
- **(Vinyl)**
|
||||||
|
- **(Anniversary Edition)**
|
||||||
|
|
||||||
|
## 6. Требования к обложке
|
||||||
|
|
||||||
|
- Разрешение: 200×200 - 500×500 px (описание)
|
||||||
|
- Для тегов: 200×200 - 600×600 px
|
||||||
|
- Размер файла: до 300-500 KB
|
||||||
|
- 🚫 Анимация запрещена
|
||||||
|
- 🚫 Реклама запрещена
|
||||||
|
|
||||||
|
## 7. Треклист
|
||||||
|
|
||||||
|
### Формат:
|
||||||
|
```
|
||||||
|
01. Название трека (04:30)
|
||||||
|
02. Название трека (05:15)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Для сборников:
|
||||||
|
```
|
||||||
|
01. Исполнитель - Название трека (04:30)
|
||||||
|
```
|
||||||
|
|
||||||
|
Треклисты более 50 треков - под спойлер.
|
||||||
|
|
||||||
|
## 8. Локальные правила lossless
|
||||||
|
|
||||||
|
### Критерии собственного рипа:
|
||||||
|
1. Последняя версия EAC или XLD
|
||||||
|
2. Режим Test & Copy
|
||||||
|
3. Учёт нулевых семплов включён
|
||||||
|
4. Полный комплект сканов
|
||||||
|
5. Ник релизёра в логе
|
||||||
|
|
||||||
|
### Пример комментария в логе:
|
||||||
|
```
|
||||||
|
Additional command line options : -T "COMMENT=Ripped by [nickname]" -8 -V %s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Дискографии Metal
|
||||||
|
|
||||||
|
### Формат папок:
|
||||||
|
```
|
||||||
|
[Каталожный номер] Название группы - Название альбома (год)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Требования:
|
||||||
|
- Единообразие всех папок
|
||||||
|
- Каждый альбом под спойлером
|
||||||
|
- Жанр для каждого альбома (если различаются)
|
||||||
|
|
||||||
|
## 10. Запреты
|
||||||
|
|
||||||
|
### Запрещено раздавать:
|
||||||
|
- Неполные релизы
|
||||||
|
- Транскоды (апконверты)
|
||||||
|
- Сборники собственного составления
|
||||||
|
- AI-музыку
|
||||||
|
|
||||||
|
### Запрещено объединять:
|
||||||
|
- Lossless и lossy
|
||||||
|
- Red Book и Hi-Res
|
||||||
|
- Официальные и неофициальные релизы
|
||||||
|
|
||||||
|
### Запрещено в тегах:
|
||||||
|
- Графика кроме обложки
|
||||||
|
- "Трек 1", "CD 1" вместо названий
|
||||||
|
|
||||||
|
## 11. Статусы раздач
|
||||||
|
|
||||||
|
- **✓ Проверено** - соответствует правилам
|
||||||
|
- **? Сомнительно** - вопросы к качеству
|
||||||
|
- **! Не оформлено** - не соответствует требованиям
|
||||||
|
- **Повтор** - дублирует существующую
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Metal жанры
|
||||||
|
(?:Heavy|Power|Thrash|Speed|Progressive|Death|Black|Doom|Gothic|Symphonic|Folk|Viking|Pagan|Grind|Metal)(?:core)?(?:\s*Metal)?
|
||||||
|
|
||||||
|
# Комбинированные жанры
|
||||||
|
\(([A-Za-z/,\s]+Metal[A-Za-z/,\s]*)\)
|
||||||
|
|
||||||
|
# Тип релиза
|
||||||
|
\[(EP|Single|Live|Demo|Remaster|Reissue|Deluxe Edition|Box Set)\]
|
||||||
|
|
||||||
|
# Специальные издания
|
||||||
|
\((Japan|Limited|Anniversary)\s*Edition\)|\(Digipak\)|\(Vinyl\)
|
||||||
|
|
||||||
|
# Название группы (с точками)
|
||||||
|
([A-Z][A-Za-z.]+(?:\s+[A-Z][A-Za-z.]+)*)
|
||||||
|
```
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Правила оформления раздач саундтреков RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=3119778
|
||||||
|
|
||||||
|
## 1. Общие требования
|
||||||
|
|
||||||
|
### 1.1 Поиск перед созданием
|
||||||
|
Обязательно проверить наличие аналогичной раздачи.
|
||||||
|
|
||||||
|
### 1.2 Качество
|
||||||
|
Раздача более низкого битрейта закрывается как повтор при наличии более качественной.
|
||||||
|
|
||||||
|
### 1.3 Минимум треков
|
||||||
|
Не менее 3 треков в раздаче.
|
||||||
|
|
||||||
|
### 1.4 Форматы
|
||||||
|
🚫 Lossless запрещён для неофициальных саундтреков (только официальные саундтреки в lossless).
|
||||||
|
|
||||||
|
## 2. Формат заголовка
|
||||||
|
|
||||||
|
### 2.1 Soundtrack (песенная компиляция)
|
||||||
|
```
|
||||||
|
(Soundtrack / Unofficial) Название фильма (русское) / Original Title - Год, Формат Битрейт
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Score (оригинальная инструментальная музыка)
|
||||||
|
```
|
||||||
|
(Score / Unofficial) Композитор - Название фильма (русское) / Original Title - Год, Формат Битрейт
|
||||||
|
```
|
||||||
|
|
||||||
|
### Примеры:
|
||||||
|
```
|
||||||
|
(Soundtrack / Unofficial) Матрица / The Matrix - 1999, MP3 320 kbps
|
||||||
|
(Score / Unofficial) Hans Zimmer - Начало / Inception - 2010, MP3 V0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Обязательные поля
|
||||||
|
|
||||||
|
### 3.1 Жанр
|
||||||
|
```
|
||||||
|
(Soundtrack / Unofficial)
|
||||||
|
(Score / Unofficial)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Название фильма
|
||||||
|
Формат: Русское название / Оригинальное название
|
||||||
|
|
||||||
|
### 3.3 Композитор
|
||||||
|
**Обязательно только для Score.**
|
||||||
|
|
||||||
|
### 3.4 Год
|
||||||
|
Год выхода фильма/сериала (не саундтрека).
|
||||||
|
|
||||||
|
### 3.5 Продюсер/Страна
|
||||||
|
Информация о производстве фильма.
|
||||||
|
|
||||||
|
## 4. Обложка
|
||||||
|
|
||||||
|
**Размер:** 200x200 - 500x500 пикселей
|
||||||
|
|
||||||
|
Рекомендуется использовать:
|
||||||
|
- Постер фильма
|
||||||
|
- Официальную обложку саундтрека (если есть)
|
||||||
|
|
||||||
|
## 5. Треклист
|
||||||
|
|
||||||
|
### 5.1 Формат
|
||||||
|
```
|
||||||
|
01. Исполнитель - Название (продолжительность) - битрейт
|
||||||
|
02. Исполнитель - Название (продолжительность) - битрейт
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Для Score
|
||||||
|
```
|
||||||
|
01. Название (продолжительность) - битрейт
|
||||||
|
```
|
||||||
|
(композитор указывается в заголовке)
|
||||||
|
|
||||||
|
## 6. Именование папки
|
||||||
|
|
||||||
|
```
|
||||||
|
Композитор - Название фильма - Unofficial Soundtrack (Год)
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```
|
||||||
|
Название фильма - Unofficial Soundtrack (Год)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Именование файлов
|
||||||
|
|
||||||
|
✅ Правильно:
|
||||||
|
```
|
||||||
|
01 - Название
|
||||||
|
01. Название
|
||||||
|
01 Название
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ Неправильно:
|
||||||
|
```
|
||||||
|
Название - 01
|
||||||
|
01Название
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Различие Soundtrack и Score
|
||||||
|
|
||||||
|
### Soundtrack
|
||||||
|
- Песенная компиляция
|
||||||
|
- Различные исполнители
|
||||||
|
- Песни, использованные в фильме
|
||||||
|
- Не всегда оригинальные записи
|
||||||
|
|
||||||
|
### Score
|
||||||
|
- Оригинальная инструментальная музыка
|
||||||
|
- Один композитор (или группа)
|
||||||
|
- Написана специально для фильма
|
||||||
|
- Фоновая музыка, темы
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Soundtrack тип
|
||||||
|
\(Soundtrack\s*/?\s*(?:Unofficial|Official)?\)
|
||||||
|
|
||||||
|
# Score тип
|
||||||
|
\(Score\s*/?\s*(?:Unofficial|Official)?\)
|
||||||
|
|
||||||
|
# OST тег
|
||||||
|
\[?OST\]?|O\.?S\.?T\.?
|
||||||
|
|
||||||
|
# Название фильма двуязычное
|
||||||
|
([^/]+)\s*/\s*([^-]+)
|
||||||
|
|
||||||
|
# Год фильма
|
||||||
|
-\s*(\d{4})\s*,
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительные ссылки
|
||||||
|
|
||||||
|
- [Официальные саундтреки lossless](https://rutracker.org/forum/viewforum.php?f=691)
|
||||||
|
- [Официальные саундтреки lossy](https://rutracker.org/forum/viewforum.php?f=469)
|
||||||
|
- [Саундтреки к играм](https://rutracker.org/forum/viewforum.php?f=786)
|
||||||
|
- [Саундтреки к аниме](https://rutracker.org/forum/viewforum.php?f=1631)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Правила оцифровок с аналоговых носителей RuTracker
|
||||||
|
|
||||||
|
**Источник:** https://rutracker.org/forum/viewtopic.php?t=3558517
|
||||||
|
|
||||||
|
## 1. Введение
|
||||||
|
|
||||||
|
### 1.1 Область применения
|
||||||
|
|
||||||
|
Правила для музыкальных материалов, созданных оцифровкой аналогового звукового сигнала с:
|
||||||
|
- Виниловых пластинок
|
||||||
|
- Магнитофонных лент
|
||||||
|
- Других аналоговых носителей
|
||||||
|
|
||||||
|
Распространяется только на любительские оцифровки, не изданные официально на audio CD.
|
||||||
|
|
||||||
|
### 1.2 Связанные документы
|
||||||
|
|
||||||
|
- [Общие правила публикации](https://rutracker.org/forum/viewtopic.php?t=6372776)
|
||||||
|
- [Правила пользования форумом](https://rutracker.org/forum/viewtopic.php?t=1045)
|
||||||
|
|
||||||
|
## 2. Требования к содержанию раздачи
|
||||||
|
|
||||||
|
### 2.1 Запрещено включать:
|
||||||
|
|
||||||
|
1. **Lossy-форматы** (MP3, WMA, Ogg) - для них отдельные разделы
|
||||||
|
2. **WAV без lossless-сжатия** - только APE, FLAC (≥5), WV
|
||||||
|
3. **Многоканальное аудио** (DVD-Audio) - только PCM Wave в lossless
|
||||||
|
4. **Несколько вариантов одного носителя** - выкладывать отдельными раздачами
|
||||||
|
5. **Версии "до" и "после" обработки** - только финальная версия
|
||||||
|
6. **Неполный набор треков** официального носителя
|
||||||
|
7. **Видео** (кроме музыкальных клипов, караоке, концертов)
|
||||||
|
8. **Оцифровки с цифровых исходников** - для них раздел "Неофициальные сборники"
|
||||||
|
9. **PCM с частотой не кратной 44.1 или 48 кГц**
|
||||||
|
|
||||||
|
### 2.2 CUE-файл
|
||||||
|
|
||||||
|
Для image+.cue обязателен в кодировке Unicode.
|
||||||
|
|
||||||
|
## 3. Требования к оформлению
|
||||||
|
|
||||||
|
### 3.1 Обязательные поля шаблона
|
||||||
|
|
||||||
|
1. **Формат раздачи** - Bit/kHz (например, 24/96)
|
||||||
|
2. **Формат исходника** - Bit/kHz исходного материала
|
||||||
|
3. **Марка носителя** - тег носителя (см. ниже)
|
||||||
|
4. **Состояние проигрывателя** - марка, модель, картридж, игла, усилитель
|
||||||
|
5. **Дополнительные параметры** - звуковая карта, софт
|
||||||
|
6. **АЦП** - марка, модель, разрядность, частота записи
|
||||||
|
7. **Звуковая карта** - параметры оцифровки
|
||||||
|
8. **Состояние** - физическое состояние носителя
|
||||||
|
|
||||||
|
### 3.2 Теги марки носителя
|
||||||
|
|
||||||
|
| Тег | Описание |
|
||||||
|
|-----|----------|
|
||||||
|
| `[LP]` | Виниловая пластинка 12" (33 об/мин) |
|
||||||
|
| `[MINI-LP]` | Мини-альбом |
|
||||||
|
| `[EP]` | Extended Play |
|
||||||
|
| `[12"]` | 12-дюймовый сингл |
|
||||||
|
| `[10"]` | 10-дюймовая пластинка |
|
||||||
|
| `[7"]` | 7-дюймовый сингл (45 об/мин) |
|
||||||
|
|
||||||
|
### 3.3 Состояние винила (Grading)
|
||||||
|
|
||||||
|
| Код | Название | Описание |
|
||||||
|
|-----|----------|----------|
|
||||||
|
| **Mint/SS** | Новая, запечатанная | Идеальное состояние, не проигрывалась |
|
||||||
|
| **NM** | Near Mint | Почти идеальная, минимальные дефекты |
|
||||||
|
| **EX** | Excellent | Незначительные царапины, не влияющие на звук |
|
||||||
|
| **VG+** | Very Good Plus | Поверхностные шумы и щелчки |
|
||||||
|
| **VG** | Very Good | Заметные царапины, но проигрывается без проблем |
|
||||||
|
| **G** | Good | Среднее состояние, заметные шумы |
|
||||||
|
| **F/P** | Fair/Poor | Плохое состояние |
|
||||||
|
|
||||||
|
### 3.4 Оцифровки третьих лиц
|
||||||
|
|
||||||
|
Если параметры неизвестны, указать:
|
||||||
|
- Формат раздачи (Bit/kHz)
|
||||||
|
- Источник
|
||||||
|
- Автор оцифровки (если известен)
|
||||||
|
|
||||||
|
### 3.5 Обязательные графики
|
||||||
|
|
||||||
|
**Для image-рипов:** минимум 1 изображение каждого:
|
||||||
|
- Спектр
|
||||||
|
- АЧХ
|
||||||
|
- Уровень записи
|
||||||
|
|
||||||
|
**Для потрековых рипов:** минимум 2 изображения (первый и последний трек стороны)
|
||||||
|
|
||||||
|
**Требования к скриншотам:**
|
||||||
|
- Вертикальные и горизонтальные шкалы
|
||||||
|
- Кликабельные превью под спойлером
|
||||||
|
- Достаточное разрешение для чтения шкал
|
||||||
|
|
||||||
|
### 3.6 CUE для image-рипов
|
||||||
|
|
||||||
|
Публикация содержания .CUE в оформлении строго обязательна.
|
||||||
|
|
||||||
|
## Примеры заголовков
|
||||||
|
|
||||||
|
```
|
||||||
|
(Rock) [LP] [24/96] Pink Floyd - The Dark Side of the Moon - 1973, FLAC (image+.cue)
|
||||||
|
(Jazz) [LP] [NM] [24/192] Miles Davis - Kind of Blue - 1959, FLAC (tracks)
|
||||||
|
(Electronic) [EP] [VG+] [24/48] Kraftwerk - Autobahn - 1974, APE (image+.cue)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Паттерны для парсера
|
||||||
|
|
||||||
|
```regex
|
||||||
|
# Тег носителя
|
||||||
|
\[(LP|MINI-LP|EP|12"|10"|7")\]
|
||||||
|
|
||||||
|
# Состояние винила
|
||||||
|
\[(Mint|SS|NM|EX|VG\+?|G|F/?P)\]
|
||||||
|
|
||||||
|
# Формат Bit/kHz
|
||||||
|
\[(\d+)/(\d+(?:\.\d+)?)\]
|
||||||
|
|
||||||
|
# Пример комбинированный
|
||||||
|
\[(LP|EP)\]\s*\[(NM|EX|VG\+?)\]\s*\[(\d+)/(\d+)\]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительные ссылки
|
||||||
|
|
||||||
|
- [Теги в названиях тем оцифровок](https://rutracker.org/forum/viewtopic.php?t=2672956)
|
||||||
|
- [Рекомендации по оцифровке](https://rutracker.org/forum/viewtopic.php?t=966580)
|
||||||
|
- [Правила Hi-Res музыки](https://rutracker.org/forum/viewtopic.php?t=1422416)
|
||||||
@@ -21,11 +21,11 @@
|
|||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775087534,
|
"lastModified": 1777988971,
|
||||||
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
|
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
|
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -93,11 +93,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774748309,
|
"lastModified": 1777168982,
|
||||||
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
|
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
|
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -108,11 +108,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777386324,
|
"lastModified": 1778095059,
|
||||||
"narHash": "sha256-ItxAnpJ3qffijuQzMv72I9v/yi1nWHr67hqQaVuQV6c=",
|
"narHash": "sha256-LW2nru9+O0oyR3lfzgzFLwTibhINoIL/dx2/1dBMKWU=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a6c2d4f65f850a9fa8977da543f8f9949d4a1527",
|
"rev": "94a37dc9da62b41f0c70a91da739bc318d049c11",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -36,46 +36,10 @@
|
|||||||
gofmt.enable = true;
|
gofmt.enable = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
music-agregator = pkgs.buildGoModule {
|
|
||||||
pname = "music-agregator";
|
|
||||||
version = "0.1.0";
|
|
||||||
src = ./.;
|
|
||||||
vendorHash = "sha256-gad5/pLGWyU45QiEvZJ8xEKNy4K2p5OykKE0nykzh8w=";
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
pkgs.protobuf
|
|
||||||
pkgs.protoc-gen-go
|
|
||||||
pkgs.protoc-gen-go-grpc
|
|
||||||
];
|
|
||||||
|
|
||||||
preBuild = ''
|
|
||||||
export HOME=$(mktemp -d)
|
|
||||||
mkdir -p pkg/metadatapb/metadata/v1
|
|
||||||
${pkgs.protobuf}/bin/protoc \
|
|
||||||
--plugin=protoc-gen-go=${pkgs.protoc-gen-go}/bin/protoc-gen-go \
|
|
||||||
--plugin=protoc-gen-go-grpc=${pkgs.protoc-gen-go-grpc}/bin/protoc-gen-go-grpc \
|
|
||||||
--proto_path=proto \
|
|
||||||
--go_out=pkg/metadatapb --go_opt=paths=source_relative \
|
|
||||||
--go-grpc_out=pkg/metadatapb --go-grpc_opt=paths=source_relative \
|
|
||||||
proto/metadata/v1/metadata.proto
|
|
||||||
'';
|
|
||||||
|
|
||||||
subPackages = [ "cmd/server" ];
|
|
||||||
|
|
||||||
postInstall = ''
|
|
||||||
mv $out/bin/server $out/bin/music-agregator
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
formatter = pkgs.nixfmt-tree;
|
formatter = pkgs.nixfmt-tree;
|
||||||
|
|
||||||
packages = {
|
|
||||||
default = music-agregator;
|
|
||||||
inherit music-agregator;
|
|
||||||
};
|
|
||||||
|
|
||||||
checks = {
|
checks = {
|
||||||
inherit pre-commit-check;
|
inherit pre-commit-check;
|
||||||
};
|
};
|
||||||
@@ -86,7 +50,13 @@
|
|||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
pre-commit
|
pre-commit
|
||||||
gitleaks
|
gitleaks
|
||||||
|
|
||||||
plantuml
|
plantuml
|
||||||
|
just
|
||||||
|
nixd
|
||||||
|
bruno
|
||||||
|
|
||||||
|
buf
|
||||||
protobuf
|
protobuf
|
||||||
protoc-gen-go
|
protoc-gen-go
|
||||||
protoc-gen-go-grpc
|
protoc-gen-go-grpc
|
||||||
@@ -95,6 +65,8 @@
|
|||||||
gopls
|
gopls
|
||||||
gotools
|
gotools
|
||||||
go-tools
|
go-tools
|
||||||
|
|
||||||
|
opencode
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
module homelab.lan/music-agregator
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/anacrolix/torrent v1.61.0
|
||||||
|
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0
|
||||||
|
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
github.com/mewkiz/flac v1.0.13
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/riverqueue/river v0.35.1
|
||||||
|
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1
|
||||||
|
github.com/rs/zerolog v1.35.1
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
|
||||||
|
github.com/testcontainers/testcontainers-go v0.42.0
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/RoaringBitmap/roaring v1.2.3 // indirect
|
||||||
|
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||||
|
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // indirect
|
||||||
|
github.com/anacrolix/chansync v0.7.0 // indirect
|
||||||
|
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
||||||
|
github.com/anacrolix/envpprof v1.4.0 // indirect
|
||||||
|
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
|
||||||
|
github.com/anacrolix/go-libutp v1.3.2 // indirect
|
||||||
|
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
|
||||||
|
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||||
|
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
|
||||||
|
github.com/anacrolix/mmsg v1.0.1 // indirect
|
||||||
|
github.com/anacrolix/multiless v0.4.0 // indirect
|
||||||
|
github.com/anacrolix/stm v0.5.0 // indirect
|
||||||
|
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 // indirect
|
||||||
|
github.com/anacrolix/upnp v0.1.4 // indirect
|
||||||
|
github.com/anacrolix/utp v0.1.0 // indirect
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
|
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bits-and-blooms/bitset v1.2.2 // indirect
|
||||||
|
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash v1.1.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
github.com/docker/docker v28.3.2+incompatible // indirect
|
||||||
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||||
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
|
github.com/edsrzf/mmap-go v1.1.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
|
||||||
|
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/google/btree v1.1.2 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
|
github.com/huandu/xstrings v1.3.2 // indirect
|
||||||
|
github.com/icza/bitio v1.1.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
|
||||||
|
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
|
||||||
|
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
|
github.com/moby/moby/api v1.54.1 // indirect
|
||||||
|
github.com/moby/moby/client v0.4.0 // indirect
|
||||||
|
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||||
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
|
github.com/moby/term v0.5.2 // indirect
|
||||||
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
|
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||||
|
github.com/multiformats/go-varint v0.0.6 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
github.com/pion/datachannel v1.5.9 // indirect
|
||||||
|
github.com/pion/dtls/v3 v3.0.3 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.0.2 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.40 // indirect
|
||||||
|
github.com/pion/logging v0.2.3 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.15 // indirect
|
||||||
|
github.com/pion/rtp v1.8.18 // indirect
|
||||||
|
github.com/pion/sctp v1.8.33 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
|
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||||
|
github.com/pion/webrtc/v4 v4.0.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
|
github.com/protolambda/ctxlock v0.1.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/riverqueue/river/riverdriver v0.35.1 // indirect
|
||||||
|
github.com/riverqueue/river/rivershared v0.35.1 // indirect
|
||||||
|
github.com/riverqueue/river/rivertype v0.35.1 // indirect
|
||||||
|
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
|
||||||
|
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
|
github.com/tidwall/btree v1.8.1 // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.3 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
go.etcd.io/bbolt v1.3.6 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
|
go.uber.org/goleak v1.3.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
lukechampine.com/blake3 v1.1.6 // indirect
|
||||||
|
modernc.org/libc v1.22.3 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.21.1 // indirect
|
||||||
|
zombiezen.com/go/sqlite v0.13.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
google.golang.org/grpc v1.81.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
|
||||||
|
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
||||||
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
|
||||||
|
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||||
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||||
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
|
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||||
|
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
|
||||||
|
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
|
||||||
|
github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVOVmhWBY=
|
||||||
|
github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
|
||||||
|
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||||
|
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||||
|
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
|
||||||
|
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
|
||||||
|
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
|
||||||
|
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
|
||||||
|
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
|
||||||
|
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||||
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 h1:c02PsmoaChabVqAFm7pqPI1UIkDdDAjUaWa6ZmfxybQ=
|
||||||
|
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8/go.mod h1:7stWJ39LeusmMI8mjJuhFNRqep//vx0AsaySRoK9or0=
|
||||||
|
github.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0=
|
||||||
|
github.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k=
|
||||||
|
github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=
|
||||||
|
github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=
|
||||||
|
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
|
||||||
|
github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
|
||||||
|
github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
|
||||||
|
github.com/anacrolix/envpprof v1.4.0 h1:QHeIcrgHcRChhnxR8l6rlaLlRQx9zd7Q2NII6Zbt83w=
|
||||||
|
github.com/anacrolix/envpprof v1.4.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0=
|
||||||
|
github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
|
||||||
|
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0=
|
||||||
|
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=
|
||||||
|
github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M=
|
||||||
|
github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA=
|
||||||
|
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
|
||||||
|
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
|
||||||
|
github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=
|
||||||
|
github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=
|
||||||
|
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=
|
||||||
|
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=
|
||||||
|
github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
|
||||||
|
github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY=
|
||||||
|
github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
|
||||||
|
github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=
|
||||||
|
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
|
||||||
|
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
|
||||||
|
github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
|
||||||
|
github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
|
||||||
|
github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
|
||||||
|
github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw=
|
||||||
|
github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=
|
||||||
|
github.com/anacrolix/mmsg v1.0.1 h1:TxfpV7kX70m3f/O7ielL/2I3OFkMPjrRCPo7+4X5AWw=
|
||||||
|
github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc=
|
||||||
|
github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=
|
||||||
|
github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=
|
||||||
|
github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
|
||||||
|
github.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8=
|
||||||
|
github.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M=
|
||||||
|
github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk=
|
||||||
|
github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=
|
||||||
|
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 h1:oLCfNgEOR3/Z98mSwmwTM1pcqCDb/1zIjxCNn7dzVaE=
|
||||||
|
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1/go.mod h1:21cUWerw9eiu/3T3kyoChu37AVO+YFue1/H15qqubS0=
|
||||||
|
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
|
||||||
|
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
|
||||||
|
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
|
||||||
|
github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8=
|
||||||
|
github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=
|
||||||
|
github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=
|
||||||
|
github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=
|
||||||
|
github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4=
|
||||||
|
github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk=
|
||||||
|
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
|
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
|
||||||
|
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d h1:2qVb9bsAMtmAfnxXltm+6eBzrrS7SZ52c3SedsulaMI=
|
||||||
|
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM=
|
||||||
|
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
|
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
|
||||||
|
github.com/bits-and-blooms/bitset v1.2.2 h1:J5gbX05GpMdBjCvQ9MteIg2KKDExr7DrgK+Yc15FvIk=
|
||||||
|
github.com/bits-and-blooms/bitset v1.2.2/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||||
|
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA=
|
||||||
|
github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||||
|
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
|
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||||
|
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
|
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||||
|
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||||
|
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||||
|
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
|
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
|
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
|
||||||
|
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
|
||||||
|
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
|
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
|
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
|
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
|
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
|
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 h1:OyQmpAN302wAopDgwVjgs2HkFawP9ahIEqkUYz7V7CA=
|
||||||
|
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916/go.mod h1:DADrR88ONKPPeSGjFp5iEN55Arx3fi2qXZeKCYDpbmU=
|
||||||
|
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 h1:GClwZI0at7xwV0TpgUMTYr/DoTE7TJZ/tc29LcPcs7o=
|
||||||
|
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE=
|
||||||
|
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||||
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||||
|
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||||
|
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||||
|
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
|
||||||
|
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=
|
||||||
|
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
|
||||||
|
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
|
||||||
|
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
|
||||||
|
github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
|
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
|
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
|
||||||
|
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
|
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
||||||
|
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||||
|
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
||||||
|
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
|
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
|
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||||
|
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
|
||||||
|
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
|
||||||
|
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
|
||||||
|
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
|
||||||
|
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
|
||||||
|
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
|
||||||
|
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
|
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||||
|
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||||
|
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||||
|
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
|
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||||
|
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||||
|
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||||
|
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
|
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
||||||
|
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||||
|
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||||
|
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||||
|
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||||
|
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
|
||||||
|
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
|
||||||
|
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||||
|
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||||
|
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
|
||||||
|
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
|
||||||
|
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
|
||||||
|
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
|
||||||
|
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
|
||||||
|
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
|
||||||
|
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||||
|
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||||
|
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||||
|
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
|
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||||
|
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
|
||||||
|
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||||
|
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
|
||||||
|
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||||
|
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
|
||||||
|
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
|
||||||
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
|
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||||
|
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||||
|
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
|
||||||
|
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||||
|
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||||
|
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||||
|
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||||
|
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||||
|
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||||
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
|
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
|
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
|
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||||
|
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||||
|
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||||
|
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||||
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
|
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
|
||||||
|
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/riverqueue/river v0.35.1 h1:TK1LLGRdTWL7ARPbIUB+TqMnTYJ0GiCoy5Q/yEf5yBE=
|
||||||
|
github.com/riverqueue/river v0.35.1/go.mod h1:jDt0LimObI+5e6FVy7LyuIWfHftmV0wARmiK7W+9D64=
|
||||||
|
github.com/riverqueue/river/riverdriver v0.35.1 h1:zJx8SaQdMP7zVEfd8SDoe8KjVHCXoXoFfzt6v+SJtQg=
|
||||||
|
github.com/riverqueue/river/riverdriver v0.35.1/go.mod h1:Y+rQzz0uvh+pQI+mzJh3qgAGGNxestOWgjKa7mob87w=
|
||||||
|
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1 h1:GL+ztwpXgIqBin/3wNzq8h1/H8befxl61/DlLvVCAAY=
|
||||||
|
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.1/go.mod h1:5Llh5ONCFsW67dLm5+OelSWTKhliQ989JLbVMwyuN2U=
|
||||||
|
github.com/riverqueue/river/rivershared v0.35.1 h1:XEHf7yj35p5Os5r6K08q9BVaAKsvWhP9hfxEr+MwXqg=
|
||||||
|
github.com/riverqueue/river/rivershared v0.35.1/go.mod h1:YqVk7bZoojLsx58kyQ6ZU2FHP91HP4whVj6MTCtih/c=
|
||||||
|
github.com/riverqueue/river/rivertype v0.35.1 h1:7SfjZ3Hkr7gRjItMHAUzJBAHIqx41yS/4yjVPQVtNfM=
|
||||||
|
github.com/riverqueue/river/rivertype v0.35.1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
|
||||||
|
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
|
||||||
|
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
||||||
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
|
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||||
|
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
|
||||||
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
|
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
|
||||||
|
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
|
||||||
|
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
||||||
|
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||||
|
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
|
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
|
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||||
|
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
|
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||||
|
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||||
|
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||||
|
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||||
|
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||||
|
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||||
|
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||||
|
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||||
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
|
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
||||||
|
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
|
||||||
|
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
|
||||||
|
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
||||||
|
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
|
||||||
|
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
||||||
|
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||||
|
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||||
|
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
|
||||||
|
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package analysis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"homelab.lan/music-agregator/internal/audio"
|
||||||
|
"homelab.lan/music-agregator/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var audioExtensions = map[string]bool{
|
||||||
|
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
|
||||||
|
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackNumberRegex = regexp.MustCompile(`^(\d+)[\s\-._]+`)
|
||||||
|
|
||||||
|
type ReleaseAnalyzer struct {
|
||||||
|
downloads *database.DownloadRepository
|
||||||
|
downloadFiles *database.DownloadFileRepository
|
||||||
|
albumReleases *database.AlbumReleaseRepository
|
||||||
|
trackReleases *database.TrackReleaseRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReleaseAnalyzer(db *database.DB) *ReleaseAnalyzer {
|
||||||
|
return &ReleaseAnalyzer{
|
||||||
|
downloads: database.NewDownloadRepository(db.Pool),
|
||||||
|
downloadFiles: database.NewDownloadFileRepository(db.Pool),
|
||||||
|
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
|
||||||
|
trackReleases: database.NewTrackReleaseRepository(db.Pool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ReleaseAnalyzer) Analyze(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
|
||||||
|
download, err := a.downloads.GetByID(ctx, downloadID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("getting download: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := a.downloadFiles.GetByDownloadID(ctx, downloadID)
|
||||||
|
if err != nil || len(files) == 0 {
|
||||||
|
log.Info().Str("download_id", downloadID).Msg("no download files in DB, scanning filesystem")
|
||||||
|
scanned, scanErr := ScanAndHashFiles(contentPath)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("scanning files: %w", scanErr)
|
||||||
|
}
|
||||||
|
for _, f := range scanned {
|
||||||
|
f.DownloadID = downloadID
|
||||||
|
}
|
||||||
|
if err := a.downloadFiles.CreateBatch(ctx, scanned); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("persisting scanned files: %w", err)
|
||||||
|
}
|
||||||
|
files = scanned
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioFiles []*database.DownloadFile
|
||||||
|
var hasCoverArt, hasCueSheet, hasRipLog bool
|
||||||
|
for _, f := range files {
|
||||||
|
if audioExtensions["."+f.FileType] {
|
||||||
|
audioFiles = append(audioFiles, f)
|
||||||
|
}
|
||||||
|
switch f.FileType {
|
||||||
|
case "jpg", "jpeg", "png", "gif", "webp":
|
||||||
|
hasCoverArt = true
|
||||||
|
case "cue":
|
||||||
|
hasCueSheet = true
|
||||||
|
case "log":
|
||||||
|
hasRipLog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(audioFiles) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("no audio files found for download %s", downloadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackReleases []*database.TrackRelease
|
||||||
|
var totalSize int64
|
||||||
|
var totalDuration int
|
||||||
|
formatCounts := make(map[string]int)
|
||||||
|
var firstBitDepth, firstSampleRate, firstChannels int
|
||||||
|
var firstIsLossless bool
|
||||||
|
|
||||||
|
for i, f := range audioFiles {
|
||||||
|
fullPath := filepath.Join(contentPath, f.FilePath)
|
||||||
|
info, err := audio.Analyze(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("path", f.FilePath).Msg("failed to analyze audio file")
|
||||||
|
info = &audio.TrackInfo{
|
||||||
|
Format: strings.ToUpper(f.FileType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
firstBitDepth = info.BitDepth
|
||||||
|
firstSampleRate = info.SampleRate
|
||||||
|
firstChannels = info.Channels
|
||||||
|
firstIsLossless = info.IsLossless
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCounts[info.Format]++
|
||||||
|
totalSize += f.FileSize
|
||||||
|
totalDuration += info.DurationMs
|
||||||
|
|
||||||
|
trackNum := extractTrackNumber(f.FilePath)
|
||||||
|
title := extractTitle(f.FilePath)
|
||||||
|
|
||||||
|
tr := &database.TrackRelease{
|
||||||
|
Title: title,
|
||||||
|
TrackNumber: trackNum,
|
||||||
|
DiscNumber: 1,
|
||||||
|
Format: info.Format,
|
||||||
|
Channels: info.Channels,
|
||||||
|
FileSize: f.FileSize,
|
||||||
|
FilePath: f.FilePath,
|
||||||
|
}
|
||||||
|
if info.DurationMs > 0 {
|
||||||
|
dur := info.DurationMs
|
||||||
|
tr.DurationMs = &dur
|
||||||
|
}
|
||||||
|
if info.BitDepth > 0 {
|
||||||
|
bd := info.BitDepth
|
||||||
|
tr.BitDepth = &bd
|
||||||
|
}
|
||||||
|
if info.SampleRate > 0 {
|
||||||
|
sr := info.SampleRate
|
||||||
|
tr.SampleRate = &sr
|
||||||
|
}
|
||||||
|
if info.BitrateKbps > 0 {
|
||||||
|
br := info.BitrateKbps
|
||||||
|
tr.BitrateKbps = &br
|
||||||
|
}
|
||||||
|
trackReleases = append(trackReleases, tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
dominantFormat := ""
|
||||||
|
maxCount := 0
|
||||||
|
for format, count := range formatCounts {
|
||||||
|
if count > maxCount {
|
||||||
|
dominantFormat = format
|
||||||
|
maxCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var source *string
|
||||||
|
if hasRipLog {
|
||||||
|
s := "CD"
|
||||||
|
source = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
release := &database.AlbumRelease{
|
||||||
|
AlbumID: download.AlbumID,
|
||||||
|
DownloadID: downloadID,
|
||||||
|
Format: dominantFormat,
|
||||||
|
Channels: firstChannels,
|
||||||
|
IsLossless: firstIsLossless,
|
||||||
|
Source: source,
|
||||||
|
TotalSize: totalSize,
|
||||||
|
TotalDurationMs: totalDuration,
|
||||||
|
TrackCount: len(audioFiles),
|
||||||
|
HasCoverArt: hasCoverArt,
|
||||||
|
HasCueSheet: hasCueSheet,
|
||||||
|
HasRipLog: hasRipLog,
|
||||||
|
Path: contentPath,
|
||||||
|
}
|
||||||
|
if firstBitDepth > 0 {
|
||||||
|
release.BitDepth = &firstBitDepth
|
||||||
|
}
|
||||||
|
if firstSampleRate > 0 {
|
||||||
|
release.SampleRate = &firstSampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return release, trackReleases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ReleaseAnalyzer) AnalyzeAndPersist(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
|
||||||
|
release, trackReleases, err := a.Analyze(ctx, downloadID, contentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.albumReleases.DeleteByDownloadID(ctx, downloadID); err != nil {
|
||||||
|
log.Warn().Err(err).Str("download_id", downloadID).Msg("failed to delete existing album release")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.albumReleases.Create(ctx, release); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating album release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tr := range trackReleases {
|
||||||
|
tr.AlbumReleaseID = release.ID
|
||||||
|
}
|
||||||
|
if err := a.trackReleases.CreateBatch(ctx, trackReleases); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating track releases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return release, trackReleases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTrackNumber(filePath string) int {
|
||||||
|
base := filepath.Base(filePath)
|
||||||
|
matches := trackNumberRegex.FindStringSubmatch(base)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
var num int
|
||||||
|
fmt.Sscanf(matches[1], "%d", &num)
|
||||||
|
return num
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTitle(filePath string) string {
|
||||||
|
base := filepath.Base(filePath)
|
||||||
|
ext := filepath.Ext(base)
|
||||||
|
name := strings.TrimSuffix(base, ext)
|
||||||
|
name = trackNumberRegex.ReplaceAllString(name, "")
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAudioExtension(ext string) bool {
|
||||||
|
return audioExtensions[ext]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanAndHashFiles(rootPath string) ([]*database.DownloadFile, error) {
|
||||||
|
var files []*database.DownloadFile
|
||||||
|
|
||||||
|
err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
relPath, _ := filepath.Rel(rootPath, path)
|
||||||
|
|
||||||
|
fileType := strings.TrimPrefix(ext, ".")
|
||||||
|
if fileType == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
df := &database.DownloadFile{
|
||||||
|
FilePath: relPath,
|
||||||
|
FileSize: info.Size(),
|
||||||
|
FileType: fileType,
|
||||||
|
}
|
||||||
|
|
||||||
|
if IsAudioExtension(ext) || ext == ".cue" || ext == ".log" {
|
||||||
|
hash, err := hashFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("path", path).Msg("failed to hash file")
|
||||||
|
} else {
|
||||||
|
df.SHA256Hash = hash
|
||||||
|
now := time.Now()
|
||||||
|
df.VerifiedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, df)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashFile(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("opening file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", fmt.Errorf("hashing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackInfo struct {
|
||||||
|
Format string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Channels int
|
||||||
|
DurationMs int
|
||||||
|
BitrateKbps int
|
||||||
|
IsLossless bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Analyze(filePath string) (*TrackInfo, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".flac":
|
||||||
|
return analyzeFLAC(filePath)
|
||||||
|
case ".mp3":
|
||||||
|
return analyzeMP3(filePath)
|
||||||
|
case ".aac", ".m4a", ".ogg", ".wav", ".ape", ".wv", ".alac":
|
||||||
|
return &TrackInfo{
|
||||||
|
Format: strings.ToUpper(strings.TrimPrefix(ext, ".")),
|
||||||
|
IsLossless: ext == ".wav" || ext == ".ape" || ext == ".wv" || ext == ".alac",
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported audio format: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mewkiz/flac"
|
||||||
|
)
|
||||||
|
|
||||||
|
func analyzeFLAC(filePath string) (*TrackInfo, error) {
|
||||||
|
stream, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing FLAC: %w", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
info := stream.Info
|
||||||
|
durationMs := int(info.NSamples * 1000 / uint64(info.SampleRate))
|
||||||
|
|
||||||
|
return &TrackInfo{
|
||||||
|
Format: "FLAC",
|
||||||
|
BitDepth: int(info.BitsPerSample),
|
||||||
|
SampleRate: int(info.SampleRate),
|
||||||
|
Channels: int(info.NChannels),
|
||||||
|
DurationMs: durationMs,
|
||||||
|
IsLossless: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tcolgate/mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func analyzeMP3(filePath string) (*TrackInfo, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening MP3: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
decoder := mp3.NewDecoder(f)
|
||||||
|
var frame mp3.Frame
|
||||||
|
var skipped int
|
||||||
|
var totalDuration time.Duration
|
||||||
|
var sampleRate, channels, bitrate int
|
||||||
|
var frameCount int
|
||||||
|
|
||||||
|
for {
|
||||||
|
err := decoder.Decode(&frame, &skipped)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if frameCount == 0 {
|
||||||
|
sampleRate = int(frame.Header().SampleRate())
|
||||||
|
channels = channelCount(frame.Header().ChannelMode())
|
||||||
|
bitrate = int(frame.Header().BitRate()) / 1000
|
||||||
|
}
|
||||||
|
totalDuration += frame.Duration()
|
||||||
|
frameCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TrackInfo{
|
||||||
|
Format: "MP3",
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
Channels: channels,
|
||||||
|
DurationMs: int(totalDuration.Milliseconds()),
|
||||||
|
BitrateKbps: bitrate,
|
||||||
|
IsLossless: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelCount(mode mp3.FrameChannelMode) int {
|
||||||
|
if mode == mp3.SingleChannel {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
IndexerTypeJackett IndexerType = "jackett"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TorrentClientQbittorrent TorrentClientType = "qbittorrent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IndexerType string
|
||||||
|
type TorrentClientType string
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
App struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port string `yaml:"port"`
|
||||||
|
} `yaml:"app"`
|
||||||
|
|
||||||
|
Database struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
} `yaml:"database"`
|
||||||
|
|
||||||
|
Indexer struct {
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Port string `yaml:"port"`
|
||||||
|
Type IndexerType `yaml:"type"`
|
||||||
|
ApiKey string `yaml:"api_key"`
|
||||||
|
Cache CacheConfig `yaml:"cache"`
|
||||||
|
} `yaml:"indexer"`
|
||||||
|
|
||||||
|
Torrent struct {
|
||||||
|
ClientType TorrentClientType `yaml:"client_type"`
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
ContainerName string `yaml:"container_name"`
|
||||||
|
} `yaml:"torrent"`
|
||||||
|
|
||||||
|
Metadata struct {
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
} `yaml:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
RefreshInterval time.Duration `yaml:"refresh_interval"`
|
||||||
|
TTL time.Duration `yaml:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IndexerType) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
|
var value string
|
||||||
|
if err := unmarshal(&value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch IndexerType(value) {
|
||||||
|
case IndexerTypeJackett:
|
||||||
|
*t = IndexerType(value)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown indexer type: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfig() *Config {
|
||||||
|
config := Config{}
|
||||||
|
config.App.Host = "localhost"
|
||||||
|
config.App.Port = "8080"
|
||||||
|
|
||||||
|
return &config
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlbumEvent struct {
|
||||||
|
ID string
|
||||||
|
Seq int64
|
||||||
|
WorkflowRunID string
|
||||||
|
AlbumID string
|
||||||
|
EventType string
|
||||||
|
Step string
|
||||||
|
Message string
|
||||||
|
DataJSON []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumEventRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAlbumEventRepository(pool *pgxpool.Pool) *AlbumEventRepository {
|
||||||
|
return &AlbumEventRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumEventRepository) Create(ctx context.Context, event *AlbumEvent) error {
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO album_events (workflow_run_id, album_id, event_type, step, message, data_json)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, seq, created_at`,
|
||||||
|
event.WorkflowRunID, event.AlbumID, event.EventType, event.Step, event.Message, event.DataJSON,
|
||||||
|
).Scan(&event.ID, &event.Seq, &event.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating album event: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumEventRepository) GetByWorkflowRun(ctx context.Context, workflowRunID string) ([]*AlbumEvent, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
|
||||||
|
FROM album_events WHERE workflow_run_id = $1 ORDER BY seq`, workflowRunID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing album events by workflow run: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []*AlbumEvent
|
||||||
|
for rows.Next() {
|
||||||
|
event := &AlbumEvent{}
|
||||||
|
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning album event: %w", err)
|
||||||
|
}
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumEventRepository) GetByAlbum(ctx context.Context, albumID string, afterSeq int64, limit int) ([]*AlbumEvent, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
|
||||||
|
FROM album_events WHERE album_id = $1 AND seq > $2 ORDER BY seq LIMIT $3`, albumID, afterSeq, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing album events by album: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []*AlbumEvent
|
||||||
|
for rows.Next() {
|
||||||
|
event := &AlbumEvent{}
|
||||||
|
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning album event: %w", err)
|
||||||
|
}
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumEventRepository) GetLatestSeq(ctx context.Context) (int64, error) {
|
||||||
|
var seq int64
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(MAX(seq), 0) FROM album_events`,
|
||||||
|
).Scan(&seq)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("getting latest album event seq: %w", err)
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumEventRepository) GetAfterSeq(ctx context.Context, afterSeq int64) ([]*AlbumEvent, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, seq, workflow_run_id, album_id, event_type, step, message, data_json, created_at
|
||||||
|
FROM album_events WHERE seq > $1 ORDER BY seq LIMIT 1000`, afterSeq,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing album events after seq: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []*AlbumEvent
|
||||||
|
for rows.Next() {
|
||||||
|
event := &AlbumEvent{}
|
||||||
|
if err := rows.Scan(&event.ID, &event.Seq, &event.WorkflowRunID, &event.AlbumID, &event.EventType, &event.Step, &event.Message, &event.DataJSON, &event.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning album event: %w", err)
|
||||||
|
}
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlbumRelease struct {
|
||||||
|
ID string
|
||||||
|
AlbumID string
|
||||||
|
DownloadID string
|
||||||
|
Format string
|
||||||
|
BitDepth *int
|
||||||
|
SampleRate *int
|
||||||
|
Channels int
|
||||||
|
IsLossless bool
|
||||||
|
Source *string
|
||||||
|
TotalSize int64
|
||||||
|
TotalDurationMs int
|
||||||
|
TrackCount int
|
||||||
|
HasCoverArt bool
|
||||||
|
HasCueSheet bool
|
||||||
|
HasRipLog bool
|
||||||
|
Path string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumReleaseRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAlbumReleaseRepository(pool *pgxpool.Pool) *AlbumReleaseRepository {
|
||||||
|
return &AlbumReleaseRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumReleaseRepository) Create(ctx context.Context, ar *AlbumRelease) error {
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO album_releases (album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
ar.AlbumID, ar.DownloadID, ar.Format, ar.BitDepth, ar.SampleRate, ar.Channels, ar.IsLossless, ar.Source, ar.TotalSize, ar.TotalDurationMs, ar.TrackCount, ar.HasCoverArt, ar.HasCueSheet, ar.HasRipLog, ar.Path,
|
||||||
|
).Scan(&ar.ID, &ar.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating album release: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumReleaseRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*AlbumRelease, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
|
||||||
|
FROM album_releases WHERE album_id = $1 ORDER BY created_at DESC`, albumID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing album releases: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var releases []*AlbumRelease
|
||||||
|
for rows.Next() {
|
||||||
|
ar := &AlbumRelease{}
|
||||||
|
if err := rows.Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning album release: %w", err)
|
||||||
|
}
|
||||||
|
releases = append(releases, ar)
|
||||||
|
}
|
||||||
|
return releases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumReleaseRepository) GetByDownloadID(ctx context.Context, downloadID string) (*AlbumRelease, error) {
|
||||||
|
ar := &AlbumRelease{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
|
||||||
|
FROM album_releases WHERE download_id = $1`, downloadID,
|
||||||
|
).Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting album release by download: %w", err)
|
||||||
|
}
|
||||||
|
return ar, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumReleaseRepository) DeleteByDownloadID(ctx context.Context, downloadID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM album_releases WHERE download_id = $1`, downloadID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting album release by download: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Album struct {
|
||||||
|
ID string
|
||||||
|
ExternalID string
|
||||||
|
ArtistID string
|
||||||
|
Title string
|
||||||
|
AlbumType string
|
||||||
|
ReleaseDate *time.Time
|
||||||
|
TotalTracks int
|
||||||
|
TotalDiscs int
|
||||||
|
Label string
|
||||||
|
Genres []string
|
||||||
|
CoverURL string
|
||||||
|
MonitorState MonitorState
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
|
||||||
|
return &AlbumRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumRepository) Create(ctx context.Context, a *Album) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO albums (external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
album_type = EXCLUDED.album_type,
|
||||||
|
release_date = EXCLUDED.release_date,
|
||||||
|
total_tracks = EXCLUDED.total_tracks,
|
||||||
|
total_discs = EXCLUDED.total_discs,
|
||||||
|
label = EXCLUDED.label,
|
||||||
|
genres = EXCLUDED.genres,
|
||||||
|
cover_url = EXCLUDED.cover_url,
|
||||||
|
monitor_state = CASE
|
||||||
|
WHEN albums.monitor_state = 'excluded' THEN albums.monitor_state
|
||||||
|
WHEN albums.monitor_state = 'monitored' THEN albums.monitor_state
|
||||||
|
ELSE EXCLUDED.monitor_state
|
||||||
|
END,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.MonitorState,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating album: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumRepository) CreateBatch(ctx context.Context, albums []*Album) error {
|
||||||
|
if len(albums) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := &pgx.Batch{}
|
||||||
|
for _, a := range albums {
|
||||||
|
batch.Queue(
|
||||||
|
`INSERT INTO albums (external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
album_type = EXCLUDED.album_type,
|
||||||
|
release_date = EXCLUDED.release_date,
|
||||||
|
total_tracks = EXCLUDED.total_tracks,
|
||||||
|
total_discs = EXCLUDED.total_discs,
|
||||||
|
label = EXCLUDED.label,
|
||||||
|
genres = EXCLUDED.genres,
|
||||||
|
cover_url = EXCLUDED.cover_url,
|
||||||
|
monitor_state = CASE
|
||||||
|
WHEN albums.monitor_state = 'excluded' THEN albums.monitor_state
|
||||||
|
WHEN albums.monitor_state = 'monitored' THEN albums.monitor_state
|
||||||
|
ELSE EXCLUDED.monitor_state
|
||||||
|
END,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.MonitorState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := r.pool.SendBatch(ctx, batch)
|
||||||
|
defer results.Close()
|
||||||
|
|
||||||
|
for range albums {
|
||||||
|
if _, err := results.Exec(); err != nil {
|
||||||
|
return fmt.Errorf("batch creating album: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string) (*Album, error) {
|
||||||
|
a := &Album{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
|
FROM albums WHERE external_id = $1`, externalID,
|
||||||
|
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting album: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error) {
|
||||||
|
a := &Album{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
|
FROM albums WHERE id = $1`, id,
|
||||||
|
).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting album: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([]*Album, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
|
FROM albums WHERE artist_id = $1 ORDER BY release_date DESC`, artistID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing albums: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var albums []*Album
|
||||||
|
for rows.Next() {
|
||||||
|
a := &Album{}
|
||||||
|
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning album: %w", err)
|
||||||
|
}
|
||||||
|
albums = append(albums, a)
|
||||||
|
}
|
||||||
|
return albums, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, monitor_state, created_at, updated_at
|
||||||
|
FROM albums WHERE monitor_state = 'monitored' ORDER BY release_date DESC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing monitored albums: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var albums []*Album
|
||||||
|
for rows.Next() {
|
||||||
|
a := &Album{}
|
||||||
|
if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning album: %w", err)
|
||||||
|
}
|
||||||
|
albums = append(albums, a)
|
||||||
|
}
|
||||||
|
return albums, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlbumRepository) SetMonitorState(ctx context.Context, id string, state MonitorState) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE albums SET monitor_state = $1, updated_at = NOW() WHERE id = $2`, state, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating monitor state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Artist struct {
|
||||||
|
ID string
|
||||||
|
ExternalID string
|
||||||
|
Name string
|
||||||
|
ArtistType string
|
||||||
|
Country string
|
||||||
|
Genres []string
|
||||||
|
ImageURL string
|
||||||
|
MonitorState MonitorState
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository {
|
||||||
|
return &ArtistRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
artist_type = EXCLUDED.artist_type,
|
||||||
|
country = EXCLUDED.country,
|
||||||
|
genres = EXCLUDED.genres,
|
||||||
|
image_url = EXCLUDED.image_url,
|
||||||
|
monitor_state = CASE
|
||||||
|
WHEN artists.monitor_state = 'excluded' THEN artists.monitor_state
|
||||||
|
WHEN artists.monitor_state = 'monitored' THEN artists.monitor_state
|
||||||
|
ELSE EXCLUDED.monitor_state
|
||||||
|
END,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, created_at, updated_at`,
|
||||||
|
a.ExternalID, a.Name, a.ArtistType, a.Country, a.Genres, a.ImageURL, a.MonitorState,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating artist: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtistRepository) GetByExternalID(ctx context.Context, externalID string) (*Artist, error) {
|
||||||
|
a := &Artist{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
|
||||||
|
FROM artists WHERE external_id = $1`, externalID,
|
||||||
|
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting artist: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtistRepository) GetAll(ctx context.Context) ([]*Artist, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
|
||||||
|
FROM artists ORDER BY name ASC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing artists: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var artists []*Artist
|
||||||
|
for rows.Next() {
|
||||||
|
a := &Artist{}
|
||||||
|
if err := rows.Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning artist: %w", err)
|
||||||
|
}
|
||||||
|
artists = append(artists, a)
|
||||||
|
}
|
||||||
|
return artists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtistRepository) GetByID(ctx context.Context, id string) (*Artist, error) {
|
||||||
|
a := &Artist{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at
|
||||||
|
FROM artists WHERE id = $1`, id,
|
||||||
|
).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting artist: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MonitorState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Monitored MonitorState = "monitored"
|
||||||
|
Unmonitored MonitorState = "unmonitored"
|
||||||
|
Excluded MonitorState = "excluded"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(ctx context.Context, databaseURL string) (*DB, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connecting to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("pinging database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Str("url", databaseURL).Msg("database connected")
|
||||||
|
|
||||||
|
return &DB{Pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Close() {
|
||||||
|
db.Pool.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadFile struct {
|
||||||
|
ID string
|
||||||
|
DownloadID string
|
||||||
|
TrackID *string
|
||||||
|
FilePath string
|
||||||
|
FileSize int64
|
||||||
|
FileType string
|
||||||
|
SHA256Hash string
|
||||||
|
VerifiedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadFileRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadFileRepository(pool *pgxpool.Pool) *DownloadFileRepository {
|
||||||
|
return &DownloadFileRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadFileRepository) Create(ctx context.Context, f *DownloadFile) error {
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO download_files (download_id, track_id, file_path, file_size, file_type, sha256_hash)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
f.DownloadID, f.TrackID, f.FilePath, f.FileSize, f.FileType, f.SHA256Hash,
|
||||||
|
).Scan(&f.ID, &f.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating download file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadFileRepository) CreateBatch(ctx context.Context, files []*DownloadFile) error {
|
||||||
|
for _, f := range files {
|
||||||
|
if err := r.Create(ctx, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadFileRepository) GetByDownloadID(ctx context.Context, downloadID string) ([]*DownloadFile, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, download_id, track_id, file_path, file_size, file_type, sha256_hash, verified_at, created_at
|
||||||
|
FROM download_files WHERE download_id = $1 ORDER BY file_path`, downloadID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing download files: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []*DownloadFile
|
||||||
|
for rows.Next() {
|
||||||
|
f := &DownloadFile{}
|
||||||
|
if err := rows.Scan(&f.ID, &f.DownloadID, &f.TrackID, &f.FilePath, &f.FileSize, &f.FileType, &f.SHA256Hash, &f.VerifiedAt, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning download file: %w", err)
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadFileRepository) SetHash(ctx context.Context, id string, hash string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE download_files SET sha256_hash = $1, verified_at = NOW() WHERE id = $2`, hash, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting file hash: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadFileRepository) GetUnverified(ctx context.Context) ([]*DownloadFile, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, download_id, track_id, file_path, file_size, file_type, sha256_hash, verified_at, created_at
|
||||||
|
FROM download_files WHERE sha256_hash IS NULL OR verified_at < NOW() - INTERVAL '30 days'
|
||||||
|
ORDER BY created_at`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing unverified files: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []*DownloadFile
|
||||||
|
for rows.Next() {
|
||||||
|
f := &DownloadFile{}
|
||||||
|
if err := rows.Scan(&f.ID, &f.DownloadID, &f.TrackID, &f.FilePath, &f.FileSize, &f.FileType, &f.SHA256Hash, &f.VerifiedAt, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning download file: %w", err)
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Download struct {
|
||||||
|
ID string
|
||||||
|
TorrentID string
|
||||||
|
AlbumID string
|
||||||
|
Format string
|
||||||
|
Quality string
|
||||||
|
State string
|
||||||
|
QbitHash string
|
||||||
|
SavePath *string
|
||||||
|
ErrorMessage *string
|
||||||
|
QueuedAt time.Time
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadRepository(pool *pgxpool.Pool) *DownloadRepository {
|
||||||
|
return &DownloadRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) Create(ctx context.Context, d *Download) error {
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash, save_path)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, queued_at, created_at, updated_at`,
|
||||||
|
d.TorrentID, d.AlbumID, d.Format, d.Quality, d.State, d.QbitHash, d.SavePath,
|
||||||
|
).Scan(&d.ID, &d.QueuedAt, &d.CreatedAt, &d.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating download: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) UpdateState(ctx context.Context, id string, state string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE downloads SET state = $1, updated_at = NOW() WHERE id = $2`, state, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating download state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) SetStarted(ctx context.Context, id string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE downloads SET state = 'downloading', started_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting download started: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) SetCompleted(ctx context.Context, id string, savePath string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE downloads SET state = 'completed', save_path = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2`, savePath, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting download completed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) SetFailed(ctx context.Context, id string, errorMsg string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE downloads SET state = 'failed', error_message = $1, updated_at = NOW() WHERE id = $2`, errorMsg, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting download failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) SetCancelled(ctx context.Context, id string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE downloads SET state = 'cancelled', updated_at = NOW() WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting download cancelled: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) SetCancelledByQbitHash(ctx context.Context, hash string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE downloads SET state = 'cancelled', updated_at = NOW() WHERE qbit_hash = $1 AND state NOT IN ('completed', 'failed', 'cancelled')`, hash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting download cancelled by hash: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Download, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM downloads WHERE album_id = $1 ORDER BY created_at DESC`, albumID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing downloads: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var downloads []*Download
|
||||||
|
for rows.Next() {
|
||||||
|
d := &Download{}
|
||||||
|
if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning download: %w", err)
|
||||||
|
}
|
||||||
|
downloads = append(downloads, d)
|
||||||
|
}
|
||||||
|
return downloads, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetActiveByTorrentID(ctx context.Context, torrentID string) (*Download, error) {
|
||||||
|
d := &Download{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM downloads WHERE torrent_id = $1 AND state NOT IN ('failed')
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, torrentID,
|
||||||
|
).Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting active download by torrent: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetActive(ctx context.Context) ([]*Download, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM downloads WHERE state IN ('pending', 'downloading') ORDER BY created_at`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing active downloads: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var downloads []*Download
|
||||||
|
for rows.Next() {
|
||||||
|
d := &Download{}
|
||||||
|
if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning download: %w", err)
|
||||||
|
}
|
||||||
|
downloads = append(downloads, d)
|
||||||
|
}
|
||||||
|
return downloads, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetByID(ctx context.Context, id string) (*Download, error) {
|
||||||
|
d := &Download{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM downloads WHERE id = $1`, id,
|
||||||
|
).Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting download by id: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetLatestByAlbumIDs(ctx context.Context, albumIDs []string) (map[string]*Download, error) {
|
||||||
|
if len(albumIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT DISTINCT ON (album_id) id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM downloads WHERE album_id = ANY($1) ORDER BY album_id, created_at DESC`, albumIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("batch listing downloads: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
result := make(map[string]*Download, len(albumIDs))
|
||||||
|
for rows.Next() {
|
||||||
|
d := &Download{}
|
||||||
|
if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning download: %w", err)
|
||||||
|
}
|
||||||
|
result[d.AlbumID] = d
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM downloads
|
||||||
|
WHERE album_id = $1 AND format = $2 AND quality = $3 AND state IN ('completed', 'seeding')
|
||||||
|
)`, albumID, format, quality,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("checking album quality: %w", err)
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Torrent struct {
|
||||||
|
ID string
|
||||||
|
AlbumID string
|
||||||
|
InfoHash string
|
||||||
|
Tracker string
|
||||||
|
Title string
|
||||||
|
Format string
|
||||||
|
Quality string
|
||||||
|
Source string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Seeders int
|
||||||
|
Peers int
|
||||||
|
Size int64
|
||||||
|
TrackCount int
|
||||||
|
HasCoverArt bool
|
||||||
|
HasCueSheet bool
|
||||||
|
HasRipLog bool
|
||||||
|
DownloadLink string
|
||||||
|
TorrentFile []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTorrentRepository(pool *pgxpool.Pool) *TorrentRepository {
|
||||||
|
return &TorrentRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TorrentRepository) Create(ctx context.Context, t *Torrent) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
|
ON CONFLICT (info_hash) DO UPDATE SET
|
||||||
|
seeders = EXCLUDED.seeders,
|
||||||
|
peers = EXCLUDED.peers,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
t.AlbumID, t.InfoHash, t.Tracker, t.Title, t.Format, t.Quality, t.Source, t.BitDepth, t.SampleRate, t.Seeders, t.Peers, t.Size, t.TrackCount, t.HasCoverArt, t.HasCueSheet, t.HasRipLog, t.DownloadLink, t.TorrentFile,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating torrent: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TorrentRepository) GetByInfoHash(ctx context.Context, infoHash string) (*Torrent, error) {
|
||||||
|
t := &Torrent{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file, created_at, updated_at
|
||||||
|
FROM torrents WHERE info_hash = $1`, infoHash,
|
||||||
|
).Scan(&t.ID, &t.AlbumID, &t.InfoHash, &t.Tracker, &t.Title, &t.Format, &t.Quality, &t.Source, &t.BitDepth, &t.SampleRate, &t.Seeders, &t.Peers, &t.Size, &t.TrackCount, &t.HasCoverArt, &t.HasCueSheet, &t.HasRipLog, &t.DownloadLink, &t.TorrentFile, &t.CreatedAt, &t.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting torrent: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TorrentRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Torrent, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file, created_at, updated_at
|
||||||
|
FROM torrents WHERE album_id = $1 ORDER BY seeders DESC`, albumID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing torrents: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var torrents []*Torrent
|
||||||
|
for rows.Next() {
|
||||||
|
t := &Torrent{}
|
||||||
|
if err := rows.Scan(&t.ID, &t.AlbumID, &t.InfoHash, &t.Tracker, &t.Title, &t.Format, &t.Quality, &t.Source, &t.BitDepth, &t.SampleRate, &t.Seeders, &t.Peers, &t.Size, &t.TrackCount, &t.HasCoverArt, &t.HasCueSheet, &t.HasRipLog, &t.DownloadLink, &t.TorrentFile, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning torrent: %w", err)
|
||||||
|
}
|
||||||
|
torrents = append(torrents, t)
|
||||||
|
}
|
||||||
|
return torrents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TorrentRepository) HasAlbumInFormat(ctx context.Context, albumID string, format string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM downloads
|
||||||
|
WHERE album_id = $1 AND format = $2 AND state IN ('completed', 'seeding')
|
||||||
|
)`, albumID, format,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("checking album format: %w", err)
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackRelease struct {
|
||||||
|
ID string
|
||||||
|
AlbumReleaseID string
|
||||||
|
TrackID *string
|
||||||
|
DownloadFileID *string
|
||||||
|
Title string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
DurationMs *int
|
||||||
|
Format string
|
||||||
|
BitDepth *int
|
||||||
|
SampleRate *int
|
||||||
|
Channels int
|
||||||
|
BitrateKbps *int
|
||||||
|
FileSize int64
|
||||||
|
FilePath string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackReleaseRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrackReleaseRepository(pool *pgxpool.Pool) *TrackReleaseRepository {
|
||||||
|
return &TrackReleaseRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TrackReleaseRepository) Create(ctx context.Context, tr *TrackRelease) error {
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO track_releases (album_release_id, track_id, download_file_id, title, track_number, disc_number, duration_ms, format, bit_depth, sample_rate, channels, bitrate_kbps, file_size, file_path)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
tr.AlbumReleaseID, tr.TrackID, tr.DownloadFileID, tr.Title, tr.TrackNumber, tr.DiscNumber, tr.DurationMs, tr.Format, tr.BitDepth, tr.SampleRate, tr.Channels, tr.BitrateKbps, tr.FileSize, tr.FilePath,
|
||||||
|
).Scan(&tr.ID, &tr.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating track release: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TrackReleaseRepository) CreateBatch(ctx context.Context, tracks []*TrackRelease) error {
|
||||||
|
for _, tr := range tracks {
|
||||||
|
if err := r.Create(ctx, tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TrackReleaseRepository) GetByAlbumReleaseID(ctx context.Context, albumReleaseID string) ([]*TrackRelease, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, album_release_id, track_id, download_file_id, title, track_number, disc_number, duration_ms, format, bit_depth, sample_rate, channels, bitrate_kbps, file_size, file_path, created_at
|
||||||
|
FROM track_releases WHERE album_release_id = $1 ORDER BY disc_number, track_number`, albumReleaseID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing track releases: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tracks []*TrackRelease
|
||||||
|
for rows.Next() {
|
||||||
|
tr := &TrackRelease{}
|
||||||
|
if err := rows.Scan(&tr.ID, &tr.AlbumReleaseID, &tr.TrackID, &tr.DownloadFileID, &tr.Title, &tr.TrackNumber, &tr.DiscNumber, &tr.DurationMs, &tr.Format, &tr.BitDepth, &tr.SampleRate, &tr.Channels, &tr.BitrateKbps, &tr.FileSize, &tr.FilePath, &tr.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning track release: %w", err)
|
||||||
|
}
|
||||||
|
tracks = append(tracks, tr)
|
||||||
|
}
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
ID string
|
||||||
|
ExternalID string
|
||||||
|
AlbumID string
|
||||||
|
Title string
|
||||||
|
DurationMS int
|
||||||
|
ISRC string
|
||||||
|
DiscNumber int
|
||||||
|
TrackNumber int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrackRepository(pool *pgxpool.Pool) *TrackRepository {
|
||||||
|
return &TrackRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TrackRepository) Create(ctx context.Context, t *Track) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO tracks (external_id, album_id, title, duration_ms, isrc, disc_number, track_number)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
duration_ms = EXCLUDED.duration_ms,
|
||||||
|
isrc = EXCLUDED.isrc,
|
||||||
|
disc_number = EXCLUDED.disc_number,
|
||||||
|
track_number = EXCLUDED.track_number`,
|
||||||
|
t.ExternalID, t.AlbumID, t.Title, t.DurationMS, t.ISRC, t.DiscNumber, t.TrackNumber,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating track: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TrackRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Track, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, external_id, album_id, title, duration_ms, isrc, disc_number, track_number, created_at
|
||||||
|
FROM tracks WHERE album_id = $1 ORDER BY disc_number, track_number`, albumID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing tracks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tracks []*Track
|
||||||
|
for rows.Next() {
|
||||||
|
t := &Track{}
|
||||||
|
if err := rows.Scan(&t.ID, &t.ExternalID, &t.AlbumID, &t.Title, &t.DurationMS, &t.ISRC, &t.DiscNumber, &t.TrackNumber, &t.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning track: %w", err)
|
||||||
|
}
|
||||||
|
tracks = append(tracks, t)
|
||||||
|
}
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrWorkflowAlreadyRunning = fmt.Errorf("workflow already running for this album and quality")
|
||||||
|
|
||||||
|
type WorkflowRun struct {
|
||||||
|
ID string
|
||||||
|
AlbumID string
|
||||||
|
Quality string
|
||||||
|
Status string
|
||||||
|
ErrorMessage *string
|
||||||
|
StartedAt time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowRunRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkflowRunRepository(pool *pgxpool.Pool) *WorkflowRunRepository {
|
||||||
|
return &WorkflowRunRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkflowRunRepository) Create(ctx context.Context, run *WorkflowRun) error {
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO workflow_runs (album_id, quality, status) VALUES ($1, $2, 'running')
|
||||||
|
ON CONFLICT ON CONSTRAINT idx_workflow_runs_active DO NOTHING
|
||||||
|
RETURNING id, started_at, created_at, updated_at`,
|
||||||
|
run.AlbumID, run.Quality,
|
||||||
|
).Scan(&run.ID, &run.StartedAt, &run.CreatedAt, &run.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return ErrWorkflowAlreadyRunning
|
||||||
|
}
|
||||||
|
return fmt.Errorf("creating workflow run: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkflowRunRepository) SetCompleted(ctx context.Context, id string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE workflow_runs SET status = 'completed', completed_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting workflow run completed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkflowRunRepository) SetFailed(ctx context.Context, id string, errorMsg string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE workflow_runs SET status = 'failed', error_message = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2`, errorMsg, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting workflow run failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkflowRunRepository) SetCancelled(ctx context.Context, id string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE workflow_runs SET status = 'cancelled', completed_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting workflow run cancelled: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkflowRunRepository) GetByAlbumAndQuality(ctx context.Context, albumID string, quality string) (*WorkflowRun, error) {
|
||||||
|
run := &WorkflowRun{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM workflow_runs WHERE album_id = $1 AND quality = $2 AND status = 'running' LIMIT 1`, albumID, quality,
|
||||||
|
).Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting workflow run by album and quality: %w", err)
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkflowRunRepository) GetRunning(ctx context.Context) ([]*WorkflowRun, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM workflow_runs WHERE status = 'running' ORDER BY started_at`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing running workflow runs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var runs []*WorkflowRun
|
||||||
|
for rows.Next() {
|
||||||
|
run := &WorkflowRun{}
|
||||||
|
if err := rows.Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning workflow run: %w", err)
|
||||||
|
}
|
||||||
|
runs = append(runs, run)
|
||||||
|
}
|
||||||
|
return runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkflowRunRepository) GetByID(ctx context.Context, id string) (*WorkflowRun, error) {
|
||||||
|
run := &WorkflowRun{}
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, album_id, quality, status, error_message, started_at, completed_at, created_at, updated_at
|
||||||
|
FROM workflow_runs WHERE id = $1`, id,
|
||||||
|
).Scan(&run.ID, &run.AlbumID, &run.Quality, &run.Status, &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, &run.CreatedAt, &run.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting workflow run by id: %w", err)
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
|
||||||
|
"homelab.lan/music-agregator/internal/database"
|
||||||
|
"homelab.lan/music-agregator/internal/eventbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventPublisher interface {
|
||||||
|
PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error
|
||||||
|
PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error
|
||||||
|
PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error
|
||||||
|
SetAlbumID(albumID string)
|
||||||
|
SetWorkflowRunID(id string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dbEventPublisher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
workflowRunID string
|
||||||
|
albumID string
|
||||||
|
quality string
|
||||||
|
events *database.AlbumEventRepository
|
||||||
|
bus *eventbus.EventBus
|
||||||
|
topic string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDBEventPublisher(albumID, quality string, events *database.AlbumEventRepository, bus *eventbus.EventBus, topic string) *dbEventPublisher {
|
||||||
|
return &dbEventPublisher{
|
||||||
|
albumID: albumID,
|
||||||
|
quality: quality,
|
||||||
|
events: events,
|
||||||
|
bus: bus,
|
||||||
|
topic: topic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dbEventPublisher) SetAlbumID(albumID string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.albumID = albumID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dbEventPublisher) getAlbumID() string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.albumID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dbEventPublisher) SetWorkflowRunID(id string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.workflowRunID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dbEventPublisher) getWorkflowRunID() string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.workflowRunID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dbEventPublisher) PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error {
|
||||||
|
var dataJSON []byte
|
||||||
|
if data != nil {
|
||||||
|
var err error
|
||||||
|
dataJSON, err = json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to marshal status data to JSON")
|
||||||
|
dataJSON = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumID := p.getAlbumID()
|
||||||
|
workflowRunID := p.getWorkflowRunID()
|
||||||
|
|
||||||
|
var seq int64
|
||||||
|
if albumID != "" {
|
||||||
|
event := &database.AlbumEvent{
|
||||||
|
WorkflowRunID: workflowRunID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
EventType: "status",
|
||||||
|
Step: step.String(),
|
||||||
|
Message: msg,
|
||||||
|
DataJSON: dataJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.events.Create(ctx, event); err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to persist status event")
|
||||||
|
} else {
|
||||||
|
seq = event.Seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.bus.Publish(p.topic, &eventbus.Event{
|
||||||
|
Seq: seq,
|
||||||
|
WorkflowRunID: workflowRunID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
Quality: p.quality,
|
||||||
|
EventType: "status",
|
||||||
|
Step: step.String(),
|
||||||
|
Message: msg,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dbEventPublisher) PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error {
|
||||||
|
albumID := p.getAlbumID()
|
||||||
|
workflowRunID := p.getWorkflowRunID()
|
||||||
|
|
||||||
|
var seq int64
|
||||||
|
if albumID != "" {
|
||||||
|
event := &database.AlbumEvent{
|
||||||
|
WorkflowRunID: workflowRunID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
EventType: "error",
|
||||||
|
Step: step.String(),
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbErr := p.events.Create(ctx, event); dbErr != nil {
|
||||||
|
log.Error().Err(dbErr).Msg("failed to persist error event")
|
||||||
|
} else {
|
||||||
|
seq = event.Seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.bus.Publish(p.topic, &eventbus.Event{
|
||||||
|
Seq: seq,
|
||||||
|
WorkflowRunID: workflowRunID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
Quality: p.quality,
|
||||||
|
EventType: "error",
|
||||||
|
Step: step.String(),
|
||||||
|
Message: err.Error(),
|
||||||
|
Data: map[string]bool{"recoverable": recoverable},
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dbEventPublisher) PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error {
|
||||||
|
var dataJSON []byte
|
||||||
|
if result != nil {
|
||||||
|
var err error
|
||||||
|
dataJSON, err = json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to marshal result to JSON")
|
||||||
|
dataJSON = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumID := p.getAlbumID()
|
||||||
|
workflowRunID := p.getWorkflowRunID()
|
||||||
|
|
||||||
|
var seq int64
|
||||||
|
if albumID != "" {
|
||||||
|
event := &database.AlbumEvent{
|
||||||
|
WorkflowRunID: workflowRunID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
EventType: "result",
|
||||||
|
Step: pb.MonitorStep_MONITOR_STEP_COMPLETE.String(),
|
||||||
|
Message: "workflow completed",
|
||||||
|
DataJSON: dataJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.events.Create(ctx, event); err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to persist result event")
|
||||||
|
} else {
|
||||||
|
seq = event.Seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.bus.Publish(p.topic, &eventbus.Event{
|
||||||
|
Seq: seq,
|
||||||
|
WorkflowRunID: workflowRunID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
Quality: p.quality,
|
||||||
|
EventType: "result",
|
||||||
|
Step: pb.MonitorStep_MONITOR_STEP_COMPLETE.String(),
|
||||||
|
Message: "workflow completed",
|
||||||
|
Data: result,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamEventPublisher struct {
|
||||||
|
*dbEventPublisher
|
||||||
|
stream pb.MusicAgregatorService_MonitorAlbumStreamServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStreamEventPublisher(db *dbEventPublisher, stream pb.MusicAgregatorService_MonitorAlbumStreamServer) *streamEventPublisher {
|
||||||
|
return &streamEventPublisher{
|
||||||
|
dbEventPublisher: db,
|
||||||
|
stream: stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamEventPublisher) PublishStatus(ctx context.Context, step pb.MonitorStep, msg string, data interface{}) error {
|
||||||
|
if err := p.dbEventPublisher.PublishStatus(ctx, step, msg, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
status := &pb.StatusUpdate{
|
||||||
|
Step: step,
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := data.(type) {
|
||||||
|
case *pb.StreamAlbumInfo:
|
||||||
|
status.Data = &pb.StatusUpdate_AlbumInfo{AlbumInfo: v}
|
||||||
|
case *pb.TorrentList:
|
||||||
|
status.Data = &pb.StatusUpdate_Torrents{Torrents: v}
|
||||||
|
case *pb.ReleaseInfo:
|
||||||
|
status.Data = &pb.StatusUpdate_ReleaseInfo{ReleaseInfo: v}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||||
|
Message: &pb.MonitorAlbumStreamResponse_Status{Status: status},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamEventPublisher) PublishError(ctx context.Context, step pb.MonitorStep, err error, recoverable bool) error {
|
||||||
|
if dbErr := p.dbEventPublisher.PublishError(ctx, step, err, recoverable); dbErr != nil {
|
||||||
|
return dbErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||||
|
Message: &pb.MonitorAlbumStreamResponse_Error{
|
||||||
|
Error: &pb.ErrorUpdate{
|
||||||
|
FailedStep: step,
|
||||||
|
Message: err.Error(),
|
||||||
|
Recoverable: recoverable,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamEventPublisher) PublishResult(ctx context.Context, result *pb.MonitorAlbumResponse) error {
|
||||||
|
if err := p.dbEventPublisher.PublishResult(ctx, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||||
|
Message: &pb.MonitorAlbumStreamResponse_Result{Result: result},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package eventbus
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Seq int64
|
||||||
|
WorkflowRunID string
|
||||||
|
AlbumID string
|
||||||
|
Quality string
|
||||||
|
EventType string
|
||||||
|
Step string
|
||||||
|
Message string
|
||||||
|
Data interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscription struct {
|
||||||
|
Ring *RingBuffer[*Event]
|
||||||
|
C chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventBus struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
topics map[string]map[*Subscription]struct{}
|
||||||
|
global map[*Subscription]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *EventBus {
|
||||||
|
return &EventBus{
|
||||||
|
topics: make(map[string]map[*Subscription]struct{}),
|
||||||
|
global: make(map[*Subscription]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) Publish(topic string, event *Event) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
if subs, ok := b.topics[topic]; ok {
|
||||||
|
for sub := range subs {
|
||||||
|
sub.Ring.Push(event)
|
||||||
|
select {
|
||||||
|
case sub.C <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for sub := range b.global {
|
||||||
|
sub.Ring.Push(event)
|
||||||
|
select {
|
||||||
|
case sub.C <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) Subscribe(topic string) (*Subscription, func()) {
|
||||||
|
sub := &Subscription{
|
||||||
|
Ring: NewRingBuffer[*Event](256),
|
||||||
|
C: make(chan struct{}, 1),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.topics[topic] == nil {
|
||||||
|
b.topics[topic] = make(map[*Subscription]struct{})
|
||||||
|
}
|
||||||
|
b.topics[topic][sub] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
sub.once.Do(func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.topics[topic], sub)
|
||||||
|
if len(b.topics[topic]) == 0 {
|
||||||
|
delete(b.topics, topic)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(sub.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) SubscribeGlobal() (*Subscription, func()) {
|
||||||
|
sub := &Subscription{
|
||||||
|
Ring: NewRingBuffer[*Event](256),
|
||||||
|
C: make(chan struct{}, 1),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.global[sub] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
sub.once.Do(func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.global, sub)
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(sub.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) HasTopic(topic string) bool {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
_, ok := b.topics[topic]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package eventbus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventBus_PublishSubscribe(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
sub, cleanup := bus.Subscribe("test-topic")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
event := &Event{Seq: 1, EventType: "status", Message: "hello"}
|
||||||
|
bus.Publish("test-topic", event)
|
||||||
|
|
||||||
|
got, ok := sub.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, event, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_MultipleSubscribers(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
sub1, cleanup1 := bus.Subscribe("topic")
|
||||||
|
defer cleanup1()
|
||||||
|
sub2, cleanup2 := bus.Subscribe("topic")
|
||||||
|
defer cleanup2()
|
||||||
|
sub3, cleanup3 := bus.Subscribe("topic")
|
||||||
|
defer cleanup3()
|
||||||
|
|
||||||
|
event := &Event{Seq: 1, EventType: "status"}
|
||||||
|
bus.Publish("topic", event)
|
||||||
|
|
||||||
|
got1, ok := sub1.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, event, got1)
|
||||||
|
|
||||||
|
got2, ok := sub2.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, event, got2)
|
||||||
|
|
||||||
|
got3, ok := sub3.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, event, got3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_GlobalSubscriber(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
sub, cleanup := bus.SubscribeGlobal()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
bus.Publish("topic-a", &Event{Seq: 1})
|
||||||
|
bus.Publish("topic-b", &Event{Seq: 2})
|
||||||
|
bus.Publish("topic-c", &Event{Seq: 3})
|
||||||
|
|
||||||
|
got, ok := sub.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(1), got.Seq)
|
||||||
|
|
||||||
|
got, ok = sub.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(2), got.Seq)
|
||||||
|
|
||||||
|
got, ok = sub.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(3), got.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_TopicIsolation(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
subA, cleanupA := bus.Subscribe("topic-a")
|
||||||
|
defer cleanupA()
|
||||||
|
|
||||||
|
bus.Publish("topic-b", &Event{Seq: 1})
|
||||||
|
|
||||||
|
_, ok := subA.Ring.Pop()
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_Notification(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
sub, cleanup := bus.Subscribe("topic")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
bus.Publish("topic", &Event{Seq: 1})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sub.C:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("expected notification on channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_Unsubscribe(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
sub, cleanup := bus.Subscribe("topic")
|
||||||
|
|
||||||
|
bus.Publish("topic", &Event{Seq: 1})
|
||||||
|
_, ok := sub.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
bus.Publish("topic", &Event{Seq: 2})
|
||||||
|
_, ok = sub.Ring.Pop()
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_SlowSubscriber(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
sub, cleanup := bus.Subscribe("topic")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
bus.Publish("topic", &Event{Seq: int64(i)})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 256, sub.Ring.Len())
|
||||||
|
|
||||||
|
first, ok := sub.Ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(244), first.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_HasTopic(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
|
||||||
|
assert.False(t, bus.HasTopic("topic"))
|
||||||
|
|
||||||
|
sub, cleanup := bus.Subscribe("topic")
|
||||||
|
_ = sub
|
||||||
|
assert.True(t, bus.HasTopic("topic"))
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
assert.False(t, bus.HasTopic("topic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBus_ConcurrentPublishSubscribe(t *testing.T) {
|
||||||
|
bus := New()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
sub, cleanup := bus.Subscribe("topic")
|
||||||
|
defer cleanup()
|
||||||
|
for j := 0; j < 100; j++ {
|
||||||
|
sub.Ring.Pop()
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 100; j++ {
|
||||||
|
bus.Publish("topic", &Event{Seq: int64(id*100 + j)})
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package eventbus
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type RingBuffer[T any] struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []T
|
||||||
|
head int
|
||||||
|
tail int
|
||||||
|
count int
|
||||||
|
cap int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
|
||||||
|
return &RingBuffer[T]{
|
||||||
|
buf: make([]T, capacity),
|
||||||
|
cap: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RingBuffer[T]) Push(item T) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
r.buf[r.head] = item
|
||||||
|
r.head = (r.head + 1) % r.cap
|
||||||
|
|
||||||
|
if r.count == r.cap {
|
||||||
|
r.tail = (r.tail + 1) % r.cap
|
||||||
|
} else {
|
||||||
|
r.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RingBuffer[T]) Pop() (T, bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
var zero T
|
||||||
|
if r.count == 0 {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
item := r.buf[r.tail]
|
||||||
|
r.buf[r.tail] = zero
|
||||||
|
r.tail = (r.tail + 1) % r.cap
|
||||||
|
r.count--
|
||||||
|
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RingBuffer[T]) Len() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.count
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package eventbus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRingBuffer_PushPop(t *testing.T) {
|
||||||
|
ring := NewRingBuffer[int](5)
|
||||||
|
|
||||||
|
ring.Push(1)
|
||||||
|
ring.Push(2)
|
||||||
|
ring.Push(3)
|
||||||
|
|
||||||
|
v, ok := ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 1, v)
|
||||||
|
|
||||||
|
v, ok = ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 2, v)
|
||||||
|
|
||||||
|
v, ok = ring.Pop()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 3, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_Empty(t *testing.T) {
|
||||||
|
ring := NewRingBuffer[int](5)
|
||||||
|
|
||||||
|
v, ok := ring.Pop()
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, 0, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_OverwriteOldest(t *testing.T) {
|
||||||
|
ring := NewRingBuffer[int](4)
|
||||||
|
|
||||||
|
ring.Push(1)
|
||||||
|
ring.Push(2)
|
||||||
|
ring.Push(3)
|
||||||
|
ring.Push(4)
|
||||||
|
ring.Push(5)
|
||||||
|
ring.Push(6)
|
||||||
|
|
||||||
|
var values []int
|
||||||
|
for {
|
||||||
|
v, ok := ring.Pop()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []int{3, 4, 5, 6}, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_Len(t *testing.T) {
|
||||||
|
ring := NewRingBuffer[int](5)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, ring.Len())
|
||||||
|
|
||||||
|
ring.Push(1)
|
||||||
|
assert.Equal(t, 1, ring.Len())
|
||||||
|
|
||||||
|
ring.Push(2)
|
||||||
|
ring.Push(3)
|
||||||
|
assert.Equal(t, 3, ring.Len())
|
||||||
|
|
||||||
|
ring.Pop()
|
||||||
|
assert.Equal(t, 2, ring.Len())
|
||||||
|
|
||||||
|
ring.Push(4)
|
||||||
|
ring.Push(5)
|
||||||
|
ring.Push(6)
|
||||||
|
ring.Push(7)
|
||||||
|
assert.Equal(t, 5, ring.Len())
|
||||||
|
|
||||||
|
ring.Push(8)
|
||||||
|
assert.Equal(t, 5, ring.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_Concurrent(t *testing.T) {
|
||||||
|
ring := NewRingBuffer[int](100)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 100; j++ {
|
||||||
|
ring.Push(id*100 + j)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
ring.Pop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package hello
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
pb "homelab.lan/music-agregator/gen/music_agregator/hello/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HelloServer struct {
|
||||||
|
pb.UnimplementedHelloServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHelloServer() *HelloServer {
|
||||||
|
return &HelloServer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *HelloServer) Ping(ctx context.Context, req *pb.PingRequest) (*pb.PongResponse, error) {
|
||||||
|
return &pb.PongResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *HelloServer) Echo(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
|
||||||
|
response := &pb.EchoResponse{
|
||||||
|
Response: req.GetMsg(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HelloServer) Register(server *grpc.Server) {
|
||||||
|
pb.RegisterHelloServiceServer(server, s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheEntry struct {
|
||||||
|
Key string
|
||||||
|
URL string
|
||||||
|
Result SearchResult
|
||||||
|
CreatedAt time.Time
|
||||||
|
TTL time.Duration
|
||||||
|
RefreshInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CacheEntry) IsExpired() bool {
|
||||||
|
return time.Now().After(e.CreatedAt.Add(e.TTL))
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexerCache struct {
|
||||||
|
entries map[string]*CacheEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIndexerCache() *IndexerCache {
|
||||||
|
return &IndexerCache{
|
||||||
|
entries: make(map[string]*CacheEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IndexerCache) Get(key string) (*CacheEntry, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, ok := c.entries[key]
|
||||||
|
if !ok {
|
||||||
|
log.Trace().Str("key", key).Msg("cache miss")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.IsExpired() {
|
||||||
|
log.Trace().Str("key", key).Msg("cache expired")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Str("key", key).Int("items", len(entry.Result.Items)).Msg("cache hit")
|
||||||
|
return entry, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IndexerCache) Add(entry CacheEntry) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.entries[entry.Key] = &entry
|
||||||
|
log.Debug().Str("key", entry.Key).Int("items", len(entry.Result.Items)).Dur("ttl", entry.TTL).Dur("refresh", entry.RefreshInterval).Msg("cache entry added")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IndexerCache) Update(key string, result SearchResult) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if entry, ok := c.entries[key]; ok {
|
||||||
|
entry.Result = result
|
||||||
|
log.Debug().Str("key", key).Int("items", len(result.Items)).Msg("cache entry updated")
|
||||||
|
} else {
|
||||||
|
log.Warn().Str("key", key).Msg("cache update for missing entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IndexerCache) Remove(key string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
delete(c.entries, key)
|
||||||
|
log.Debug().Str("key", key).Msg("cache entry removed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/riverqueue/river"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheRefreshArgs struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
TTLExpires time.Time `json:"ttl_expires"`
|
||||||
|
RefreshInterval time.Duration `json:"refresh_interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CacheRefreshArgs) Kind() string { return "indexer_cache_refresh" }
|
||||||
|
|
||||||
|
type CacheRefreshWorker struct {
|
||||||
|
river.WorkerDefaults[CacheRefreshArgs]
|
||||||
|
Cache *IndexerCache
|
||||||
|
Indexer Indexer
|
||||||
|
RiverClient *river.Client[pgx.Tx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *CacheRefreshWorker) Work(ctx context.Context, job *river.Job[CacheRefreshArgs]) error {
|
||||||
|
args := job.Args
|
||||||
|
|
||||||
|
if w.Cache == nil || w.Indexer == nil {
|
||||||
|
log.Trace().Str("key", args.Key).Msg("cache disabled, discarding refresh job")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Str("key", args.Key).Int64("job_id", job.ID).Time("ttl_expires", args.TTLExpires).Msg("cache refresh worker started")
|
||||||
|
|
||||||
|
if time.Now().After(args.TTLExpires) {
|
||||||
|
w.Cache.Remove(args.Key)
|
||||||
|
log.Debug().Str("key", args.Key).Msg("cache entry TTL expired, removed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Str("key", args.Key).Str("url", args.URL).Msg("fetching fresh data from indexer")
|
||||||
|
start := time.Now()
|
||||||
|
result, err := w.Indexer.FetchURL(args.URL)
|
||||||
|
if err != nil {
|
||||||
|
retryAt := time.Now().Add(5 * time.Minute)
|
||||||
|
log.Error().Err(err).Str("key", args.Key).Time("retry_at", retryAt).Msg("cache refresh failed, scheduling retry")
|
||||||
|
w.RiverClient.Insert(ctx, args, &river.InsertOpts{
|
||||||
|
ScheduledAt: retryAt,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Trace().Str("key", args.Key).Int("items", len(result.Items)).Dur("duration", time.Since(start)).Msg("fresh data fetched")
|
||||||
|
|
||||||
|
w.Cache.Update(args.Key, result)
|
||||||
|
|
||||||
|
nextRefresh := time.Now().Add(args.RefreshInterval)
|
||||||
|
_, err = w.RiverClient.Insert(ctx, args, &river.InsertOpts{
|
||||||
|
ScheduledAt: nextRefresh,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("key", args.Key).Msg("failed to schedule next cache refresh")
|
||||||
|
} else {
|
||||||
|
log.Trace().Str("key", args.Key).Time("next_refresh", nextRefresh).Msg("next refresh scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Str("key", args.Key).Int("items", len(result.Items)).Msg("cache refreshed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/riverqueue/river"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"homelab.lan/music-agregator/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CachedIndexer struct {
|
||||||
|
inner Indexer
|
||||||
|
cache *IndexerCache
|
||||||
|
riverClient *river.Client[pgx.Tx]
|
||||||
|
cfg config.CacheConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCachedIndexer(inner Indexer, cache *IndexerCache, riverClient *river.Client[pgx.Tx], cfg config.CacheConfig) *CachedIndexer {
|
||||||
|
return &CachedIndexer{
|
||||||
|
inner: inner,
|
||||||
|
cache: cache,
|
||||||
|
riverClient: riverClient,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedIndexer) Search(query string, limit int32, tracker string) (SearchResult, error) {
|
||||||
|
key := query + "|" + tracker
|
||||||
|
log.Trace().Str("key", key).Str("query", query).Str("tracker", tracker).Msg("cached indexer search")
|
||||||
|
|
||||||
|
if entry, ok := c.cache.Get(key); ok {
|
||||||
|
log.Debug().Str("key", key).Int("items", len(entry.Result.Items)).Msg("returning cached result")
|
||||||
|
return entry.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Str("key", key).Msg("cache miss, fetching from indexer")
|
||||||
|
result, err := c.inner.Search(query, limit, tracker)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("key", key).Msg("cached indexer fetch failed")
|
||||||
|
return SearchResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.inner.BuildSearchURL(query, limit, tracker)
|
||||||
|
log.Trace().Str("key", key).Str("url", url).Int("items", len(result.Items)).Msg("caching result")
|
||||||
|
|
||||||
|
c.cache.Add(CacheEntry{
|
||||||
|
Key: key,
|
||||||
|
URL: url,
|
||||||
|
Result: result,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
TTL: c.cfg.TTL,
|
||||||
|
RefreshInterval: c.cfg.RefreshInterval,
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduleAt := time.Now().Add(c.cfg.RefreshInterval)
|
||||||
|
_, err = c.riverClient.Insert(context.Background(), CacheRefreshArgs{
|
||||||
|
Key: key,
|
||||||
|
URL: url,
|
||||||
|
TTLExpires: time.Now().Add(c.cfg.TTL),
|
||||||
|
RefreshInterval: c.cfg.RefreshInterval,
|
||||||
|
}, &river.InsertOpts{
|
||||||
|
ScheduledAt: scheduleAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("key", key).Msg("failed to schedule cache refresh job")
|
||||||
|
} else {
|
||||||
|
log.Debug().Str("key", key).Time("scheduled_at", scheduleAt).Msg("cache refresh job scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Str("key", key).Dur("ttl", c.cfg.TTL).Dur("refresh", c.cfg.RefreshInterval).Int("items", len(result.Items)).Msg("cached indexer search complete")
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedIndexer) FetchURL(url string) (SearchResult, error) {
|
||||||
|
log.Trace().Str("url", url).Msg("cached indexer fetch URL passthrough")
|
||||||
|
return c.inner.FetchURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedIndexer) BuildSearchURL(query string, limit int32, tracker string) string {
|
||||||
|
return c.inner.BuildSearchURL(query, limit, tracker)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedIndexer) Capabilities(indexerName string) (IndexerCapabilities, error) {
|
||||||
|
log.Trace().Str("indexer", indexerName).Msg("cached indexer capabilities passthrough")
|
||||||
|
return c.inner.Capabilities(indexerName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
type Filter interface {
|
||||||
|
IsKnownCategory(categories []string) bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
type Indexer interface {
|
||||||
|
Search(query string, limit int32, indexer string) (SearchResult, error)
|
||||||
|
FetchURL(url string) (SearchResult, error)
|
||||||
|
BuildSearchURL(query string, limit int32, tracker string) string
|
||||||
|
Capabilities(indexerName string) (IndexerCapabilities, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IndexerCapabilities struct {
|
||||||
|
XMLName xml.Name `xml:"caps"`
|
||||||
|
Server Server `xml:"server"`
|
||||||
|
Limits Limits `xml:"limits"`
|
||||||
|
Searching Searching `xml:"searching"`
|
||||||
|
Categories []Category `xml:"categories>category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Title string `xml:"title,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Limits struct {
|
||||||
|
Default int `xml:"default,attr"`
|
||||||
|
Max int `xml:"max,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Searching struct {
|
||||||
|
Search SearchCapability `xml:"search"`
|
||||||
|
TvSearch SearchCapability `xml:"tv-search"`
|
||||||
|
MovieSearch SearchCapability `xml:"movie-search"`
|
||||||
|
MusicSearch SearchCapability `xml:"music-search"`
|
||||||
|
AudioSearch SearchCapability `xml:"audio-search"`
|
||||||
|
BookSearch SearchCapability `xml:"book-search"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchCapability struct {
|
||||||
|
Available string `xml:"available,attr"`
|
||||||
|
SupportedParams string `xml:"supportedParams,attr"`
|
||||||
|
SearchEngine string `xml:"searchEngine,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int `xml:"id,attr"`
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Subcats []Subcat `xml:"subcat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subcat struct {
|
||||||
|
ID int `xml:"id,attr"`
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IndexerCapabilities) ToProto() *pb.CapabilitiesResponse {
|
||||||
|
return &pb.CapabilitiesResponse{
|
||||||
|
Server: &pb.Server{
|
||||||
|
Title: c.Server.Title,
|
||||||
|
},
|
||||||
|
Limits: &pb.Limits{
|
||||||
|
Default: int32(c.Limits.Default),
|
||||||
|
Max: int32(c.Limits.Max),
|
||||||
|
},
|
||||||
|
Searching: &pb.Searching{
|
||||||
|
Search: c.Searching.Search.toProto(),
|
||||||
|
TvSearch: c.Searching.TvSearch.toProto(),
|
||||||
|
MovieSearch: c.Searching.MovieSearch.toProto(),
|
||||||
|
MusicSearch: c.Searching.MusicSearch.toProto(),
|
||||||
|
AudioSearch: c.Searching.AudioSearch.toProto(),
|
||||||
|
BookSearch: c.Searching.BookSearch.toProto(),
|
||||||
|
},
|
||||||
|
Categories: c.categoriesToProto(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchCapability) toProto() *pb.SearchCapability {
|
||||||
|
var params []string
|
||||||
|
if s.SupportedParams != "" {
|
||||||
|
params = strings.Split(s.SupportedParams, ",")
|
||||||
|
}
|
||||||
|
return &pb.SearchCapability{
|
||||||
|
Available: s.Available == "yes",
|
||||||
|
SupportedParams: params,
|
||||||
|
SearchEngine: s.SearchEngine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IndexerCapabilities) categoriesToProto() []*pb.Category {
|
||||||
|
categories := make([]*pb.Category, len(c.Categories))
|
||||||
|
for i, cat := range c.Categories {
|
||||||
|
subcats := make([]*pb.Subcat, len(cat.Subcats))
|
||||||
|
for j, sub := range cat.Subcats {
|
||||||
|
subcats[j] = &pb.Subcat{
|
||||||
|
Id: int32(sub.ID),
|
||||||
|
Name: sub.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
categories[i] = &pb.Category{
|
||||||
|
Id: int32(cat.ID),
|
||||||
|
Name: cat.Name,
|
||||||
|
Subcats: subcats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return categories
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"homelab.lan/music-agregator/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JacketIndexer struct {
|
||||||
|
cfg config.Config
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIndexer(cfg config.Config) Indexer {
|
||||||
|
return &JacketIndexer{
|
||||||
|
cfg: cfg,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (indexer *JacketIndexer) BuildSearchURL(query string, limit int32, tracker string) string {
|
||||||
|
searchTracker := "all"
|
||||||
|
if len(tracker) != 0 {
|
||||||
|
searchTracker = tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
uri := fmt.Sprintf("%v/api/v2.0/indexers/%v/results/torznab?apikey=%v&cat=3010,3040&q=%v&t=search",
|
||||||
|
indexer.cfg.Indexer.Url, searchTracker, indexer.cfg.Indexer.ApiKey, url.QueryEscape(query))
|
||||||
|
if limit > 0 {
|
||||||
|
uri += fmt.Sprintf("&limit=%d", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
func (indexer *JacketIndexer) Search(query string, limit int32, tracker string) (SearchResult, error) {
|
||||||
|
uri := indexer.BuildSearchURL(query, limit, tracker)
|
||||||
|
return indexer.FetchURL(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
type JackettError struct {
|
||||||
|
Code string `xml:"code,attr"`
|
||||||
|
Description string `xml:"description,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JackettError) Error() string {
|
||||||
|
return fmt.Sprintf("jackett error %s: %s", e.Code, e.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (indexer *JacketIndexer) FetchURL(uri string) (SearchResult, error) {
|
||||||
|
log.Trace().Str("uri", uri).Msg("jackett request")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error creating request")
|
||||||
|
return SearchResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := indexer.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error making search request")
|
||||||
|
return SearchResult{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error reading search response body")
|
||||||
|
return SearchResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().
|
||||||
|
Int("status", resp.StatusCode).
|
||||||
|
Int("body_bytes", len(body)).
|
||||||
|
Dur("duration", time.Since(start)).
|
||||||
|
Msg("jackett response")
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var jackettErr JackettError
|
||||||
|
if xmlErr := xml.Unmarshal(body, &jackettErr); xmlErr == nil && jackettErr.Code != "" {
|
||||||
|
log.Error().Str("code", jackettErr.Code).Str("description", jackettErr.Description).Msg("jackett returned error")
|
||||||
|
return SearchResult{}, &jackettErr
|
||||||
|
}
|
||||||
|
return SearchResult{}, fmt.Errorf("jackett returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResult SearchResult
|
||||||
|
if err := xml.Unmarshal(body, &searchResult); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error parsing search XML")
|
||||||
|
return SearchResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Int("items", len(searchResult.Items)).Msg("jackett XML parsed")
|
||||||
|
|
||||||
|
return searchResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (indexer *JacketIndexer) Capabilities(indexerName string) (IndexerCapabilities, error) {
|
||||||
|
url := indexer.cfg.Indexer.Url
|
||||||
|
uri := fmt.Sprintf("%v/api/v2.0/indexers/%v/results/torznab/api?apikey=%v&t=caps", url, indexerName, indexer.cfg.Indexer.ApiKey)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error creating request")
|
||||||
|
return IndexerCapabilities{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := indexer.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error making capabilities request")
|
||||||
|
return IndexerCapabilities{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error reading response body")
|
||||||
|
return IndexerCapabilities{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var capabilities IndexerCapabilities
|
||||||
|
if err := xml.Unmarshal(body, &capabilities); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error parsing capabilities XML")
|
||||||
|
return IndexerCapabilities{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Str("server", capabilities.Server.Title).Msg("Parsed capabilities")
|
||||||
|
|
||||||
|
return capabilities, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||||
|
"homelab.lan/music-agregator/internal/release"
|
||||||
|
"homelab.lan/music-agregator/internal/tracker"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
XMLName xml.Name `xml:"rss"`
|
||||||
|
Items []Item `xml:"channel>item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JackettIndexer struct {
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
Link string `xml:"link"`
|
||||||
|
Guid string `xml:"guid"`
|
||||||
|
PubDate string `xml:"pubDate"`
|
||||||
|
Size int64 `xml:"size"`
|
||||||
|
Description string `xml:"description"`
|
||||||
|
Categories []string `xml:"category"`
|
||||||
|
Enclosure Enclosure `xml:"enclosure"`
|
||||||
|
TorznabAttrs []TorznabAttr `xml:"attr"`
|
||||||
|
JackettIndexer JackettIndexer `xml:"jackettindexer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Enclosure struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
Length int64 `xml:"length,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorznabAttr struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Value string `xml:"value,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchItemResult struct {
|
||||||
|
Title string
|
||||||
|
DownloadLink string
|
||||||
|
TorrentPageUrl string
|
||||||
|
PubDate string
|
||||||
|
Size int64
|
||||||
|
Description string
|
||||||
|
Categories []string
|
||||||
|
Tracker string
|
||||||
|
Seeders int
|
||||||
|
Peers int
|
||||||
|
Attrs map[string]string
|
||||||
|
Release *release.Release
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SearchItemResult) ToProto() *pb.SearchItem {
|
||||||
|
var pbAttrs []*pb.TorznabAttr
|
||||||
|
for k, v := range si.Attrs {
|
||||||
|
pbAttrs = append(pbAttrs, &pb.TorznabAttr{Name: k, Value: v})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.SearchItem{
|
||||||
|
Title: si.Title,
|
||||||
|
DownloadLink: si.DownloadLink,
|
||||||
|
TorrentPageUrl: si.TorrentPageUrl,
|
||||||
|
PubDate: si.PubDate,
|
||||||
|
Size: si.Size,
|
||||||
|
Description: si.Description,
|
||||||
|
Categories: si.Categories,
|
||||||
|
TorznabAttrs: pbAttrs,
|
||||||
|
Release: si.Release.ToProto(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResponse struct {
|
||||||
|
Items []*SearchItemResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *SearchResponse) ToProto() *pb.SearchResponse {
|
||||||
|
pbItems := make([]*pb.SearchItem, len(sr.Items))
|
||||||
|
for i, item := range sr.Items {
|
||||||
|
pbItems[i] = item.ToProto()
|
||||||
|
}
|
||||||
|
return &pb.SearchResponse{Result: pbItems}
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericParser = tracker.NewGenericParser()
|
||||||
|
|
||||||
|
func (sr *SearchResult) ToSearchResponse() *SearchResponse {
|
||||||
|
var items []*SearchItemResult
|
||||||
|
|
||||||
|
for _, item := range sr.Items {
|
||||||
|
rel := genericParser.Parse(item.Title)
|
||||||
|
|
||||||
|
log.Trace().
|
||||||
|
Str("tracker", item.JackettIndexer.ID).
|
||||||
|
Str("title", item.Title).
|
||||||
|
Str("artist", rel.Artist).
|
||||||
|
Str("album", rel.Album).
|
||||||
|
Int("year", rel.Year).
|
||||||
|
Bool("parsed", rel.ParsedSuccessfully).
|
||||||
|
Msg("parsed item")
|
||||||
|
|
||||||
|
attrs := make(map[string]string, len(item.TorznabAttrs))
|
||||||
|
for _, attr := range item.TorznabAttrs {
|
||||||
|
attrs[attr.Name] = attr.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
seeders, _ := strconv.Atoi(attrs["seeders"])
|
||||||
|
peers, _ := strconv.Atoi(attrs["peers"])
|
||||||
|
|
||||||
|
items = append(items, &SearchItemResult{
|
||||||
|
Title: item.Title,
|
||||||
|
DownloadLink: item.Link,
|
||||||
|
TorrentPageUrl: item.Guid,
|
||||||
|
PubDate: item.PubDate,
|
||||||
|
Size: item.Size,
|
||||||
|
Description: item.Description,
|
||||||
|
Categories: item.Categories,
|
||||||
|
Tracker: item.JackettIndexer.ID,
|
||||||
|
Seeders: seeders,
|
||||||
|
Peers: peers,
|
||||||
|
Attrs: attrs,
|
||||||
|
Release: rel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().
|
||||||
|
Int("total", len(sr.Items)).
|
||||||
|
Int("items", len(items)).
|
||||||
|
Msg("conversion complete")
|
||||||
|
|
||||||
|
return &SearchResponse{Items: items}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/riverqueue/river"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
|
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||||
|
"homelab.lan/music-agregator/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IndexerServer struct {
|
||||||
|
service *IndexerService
|
||||||
|
pb.UnimplementedIndexerServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIndexerServer(cfg config.Config, riverClient *river.Client[pgx.Tx], cacheWorker *CacheRefreshWorker) (*IndexerServer, error) {
|
||||||
|
service, err := NewIndexerService(cfg, riverClient, cacheWorker)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to initialize IndexerService")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IndexerServer{service: service}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *IndexerServer) Search(ctx context.Context, req *pb.SearchRequest) (*pb.SearchResponse, error) {
|
||||||
|
log.Debug().
|
||||||
|
Str("query", req.GetQuery()).
|
||||||
|
Int32("limit", req.GetLimit()).
|
||||||
|
Str("tracker", req.GetTracker()).
|
||||||
|
Msg("search started")
|
||||||
|
|
||||||
|
log.Trace().Str("query", req.GetQuery()).Msg("fetching results from indexer")
|
||||||
|
|
||||||
|
resp, err := server.service.Search(req.GetQuery(), req.GetLimit(), req.GetTracker())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("query", req.GetQuery()).Msg("search failed")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Str("query", req.GetQuery()).
|
||||||
|
Int("results", len(resp.Items)).
|
||||||
|
Msg("search completed")
|
||||||
|
|
||||||
|
return resp.ToProto(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *IndexerServer) Capabilities(ctx context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
|
||||||
|
log.Debug().Str("indexer", req.GetIndexer()).Msg("capabilities requested")
|
||||||
|
|
||||||
|
resp, err := server.service.Capabilities(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("indexer", req.GetIndexer()).Msg("capabilities failed")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *IndexerServer) Register(server *grpc.Server) {
|
||||||
|
pb.RegisterIndexerServiceServer(server, s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package indexer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/riverqueue/river"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||||
|
"homelab.lan/music-agregator/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Searcher interface {
|
||||||
|
Search(query string, limit int32, indexer string) (*SearchResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexerService struct {
|
||||||
|
indexer Indexer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIndexerService(cfg config.Config, riverClient *river.Client[pgx.Tx], cacheWorker *CacheRefreshWorker) (*IndexerService, error) {
|
||||||
|
var idx Indexer
|
||||||
|
|
||||||
|
switch cfg.Indexer.Type {
|
||||||
|
case config.IndexerTypeJackett:
|
||||||
|
idx = NewIndexer(cfg)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unable to create the indexer for type: %v", cfg.Indexer.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Indexer.Cache.Enabled && riverClient != nil {
|
||||||
|
cache := NewIndexerCache()
|
||||||
|
idx = NewCachedIndexer(idx, cache, riverClient, cfg.Indexer.Cache)
|
||||||
|
|
||||||
|
if cacheWorker != nil {
|
||||||
|
cacheWorker.Cache = cache
|
||||||
|
cacheWorker.Indexer = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Dur("ttl", cfg.Indexer.Cache.TTL).Dur("refresh", cfg.Indexer.Cache.RefreshInterval).Msg("indexer cache enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IndexerService{indexer: idx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *IndexerService) Search(query string, limit int32, indexer string) (*SearchResponse, error) {
|
||||||
|
searchResult, err := service.indexer.Search(query, limit, indexer)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to search in indexer")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Int("raw_items", len(searchResult.Items)).Msg("indexer returned results")
|
||||||
|
|
||||||
|
return searchResult.ToSearchResponse(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *IndexerService) Capabilities(req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
|
||||||
|
capabilities, err := service.indexer.Capabilities(req.GetIndexer())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get capabilities from indexer")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return capabilities.ToProto(), nil
|
||||||
|
}
|
||||||