Compare commits
10 Commits
fb2e4b9107
...
995397e495
| Author | SHA1 | Date | |
|---|---|---|---|
| 995397e495 | |||
| fde1b4cd3e | |||
| 2756cc1043 | |||
| b4e763be44 | |||
| 88372c9301 | |||
| 9f1318e79e | |||
| 35ac167952 | |||
| 80051396d8 | |||
| 54d94b939e | |||
| 3d111f9008 |
@@ -1 +1,8 @@
|
||||
use flake
|
||||
#!/usr/bin/env bash
|
||||
export GIT_CONFIG_GLOBAL=/dev/null
|
||||
|
||||
eval "$(devenv direnvrc)"
|
||||
|
||||
# You can pass flags to the devenv command
|
||||
# For example: use devenv --impure --option services.postgres.enable:bool true
|
||||
use devenv
|
||||
|
||||
@@ -12,3 +12,8 @@ vendor/
|
||||
|
||||
docs/research/*/repo/
|
||||
docs/research/*/repo-index/
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
devenv.local.yaml
|
||||
|
||||
+49
-19
@@ -4,18 +4,21 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
"github.com/metadata-agregator/internal/config"
|
||||
applog "github.com/metadata-agregator/internal/logging"
|
||||
"github.com/metadata-agregator/internal/metrics"
|
||||
"github.com/metadata-agregator/internal/provider/musicbrainz"
|
||||
"github.com/metadata-agregator/internal/repository/postgres"
|
||||
"github.com/metadata-agregator/internal/server"
|
||||
@@ -29,30 +32,54 @@ func main() {
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
applog.Init(cfg.Logging)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
services, cleanup := buildServices(ctx, cfg)
|
||||
defer cleanup()
|
||||
|
||||
srvMetrics := metrics.NewServerMetrics()
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to listen: %v", err)
|
||||
log.Fatal().Err(err).Str("addr", addr).Msg("failed to listen")
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
grpcServer := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(
|
||||
applog.RequestIDInterceptor(),
|
||||
applog.PeerInfoInterceptor(),
|
||||
srvMetrics.UnaryServerInterceptor(),
|
||||
logging.UnaryServerInterceptor(applog.InterceptorLogger(), applog.LoggingOpts()...),
|
||||
),
|
||||
grpc.ChainStreamInterceptor(
|
||||
applog.StreamRequestIDInterceptor(),
|
||||
srvMetrics.StreamServerInterceptor(),
|
||||
logging.StreamServerInterceptor(applog.InterceptorLogger(), applog.LoggingOpts()...),
|
||||
),
|
||||
)
|
||||
metadatav1.RegisterMetadataServiceServer(grpcServer, server.NewMetadataServer(services))
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go gracefulShutdown(grpcServer)
|
||||
srvMetrics.InitializeMetrics(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening on %s", addr)
|
||||
if cfg.Metrics.Enabled {
|
||||
go srvMetrics.StartHTTPServer(ctx, cfg.Metrics.Port)
|
||||
}
|
||||
|
||||
go gracefulShutdown(grpcServer, cancel)
|
||||
|
||||
log.Info().Str("addr", addr).Msg("gRPC server listening")
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve: %v", err)
|
||||
log.Fatal().Err(err).Msg("failed to serve")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +93,7 @@ func buildServices(ctx context.Context, cfg *config.Config) (map[metadatav1.Prov
|
||||
}
|
||||
|
||||
if dbURL == "" {
|
||||
log.Println("no database configured, running in provider-only mode")
|
||||
log.Warn().Msg("no database configured, running in provider-only mode")
|
||||
services[metadatav1.Provider_PROVIDER_MUSICBRAINZ] = service.NewMetadataService(
|
||||
&noopArtistRepo{},
|
||||
&noopAlbumRepo{},
|
||||
@@ -78,7 +105,7 @@ func buildServices(ctx context.Context, cfg *config.Config) (map[metadatav1.Prov
|
||||
|
||||
pool, err := connectDB(ctx, dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("database connection failed: %v", err)
|
||||
log.Fatal().Err(err).Msg("database connection failed")
|
||||
}
|
||||
|
||||
artistRepo := postgres.NewArtistRepository(pool)
|
||||
@@ -92,7 +119,7 @@ func buildServices(ctx context.Context, cfg *config.Config) (map[metadatav1.Prov
|
||||
mb,
|
||||
)
|
||||
|
||||
log.Println("database connected, caching enabled")
|
||||
log.Info().Msg("database connected, caching enabled")
|
||||
return services, func() { pool.Close() }
|
||||
}
|
||||
|
||||
@@ -100,15 +127,17 @@ func connectDB(ctx context.Context, dbURL string) (*pgxpool.Pool, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
config, err := pgxpool.ParseConfig(dbURL)
|
||||
pgxCfg, err := pgxpool.ParseConfig(dbURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.MaxConns = 10
|
||||
config.MinConns = 2
|
||||
pgxCfg.MaxConns = 10
|
||||
pgxCfg.MinConns = 2
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
log.Debug().Msg("connecting to database")
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, pgxCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,10 +150,11 @@ func connectDB(ctx context.Context, dbURL string) (*pgxpool.Pool, error) {
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func gracefulShutdown(server *grpc.Server) {
|
||||
func gracefulShutdown(srv *grpc.Server, cancel context.CancelFunc) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
log.Println("shutting down...")
|
||||
server.GracefulStop()
|
||||
sig := <-sigCh
|
||||
log.Info().Str("signal", sig.String()).Msg("shutting down")
|
||||
cancel()
|
||||
srv.GracefulStop()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ func (r *noopArtistRepo) Save(ctx context.Context, artist *domain.Artist) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopArtistRepo) SaveAll(ctx context.Context, artists []domain.Artist) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noopAlbumRepo struct{}
|
||||
|
||||
func (r *noopAlbumRepo) GetByID(ctx context.Context, id string) (*domain.Album, error) {
|
||||
@@ -39,10 +43,18 @@ func (r *noopAlbumRepo) GetByArtistID(ctx context.Context, artistID string, limi
|
||||
return &domain.SearchResult[domain.Album]{}, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) Save(ctx context.Context, album *domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) SaveAll(ctx context.Context, albums []domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noopTrackRepo struct{}
|
||||
|
||||
func (r *noopTrackRepo) GetByID(ctx context.Context, id string) (*domain.Track, error) {
|
||||
@@ -64,3 +76,7 @@ func (r *noopTrackRepo) GetByAlbumID(ctx context.Context, albumID string) ([]dom
|
||||
func (r *noopTrackRepo) Save(ctx context.Context, track *domain.Track) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopTrackRepo) SaveAlbumTracks(ctx context.Context, albumID string, tracks []domain.Track) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,3 +8,11 @@ database:
|
||||
password: metadata
|
||||
name: metadata
|
||||
sslmode: disable
|
||||
|
||||
logging:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
port: 9090
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: metadata-postgres
|
||||
environment:
|
||||
POSTGRES_USER: metadata
|
||||
POSTGRES_PASSWORD: metadata
|
||||
POSTGRES_DB: metadata
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./migrations:/docker-entrypoint-initdb.d:ro
|
||||
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U metadata -d metadata"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -1 +0,0 @@
|
||||
DROP EXTENSION IF EXISTS pg_prewarm;
|
||||
@@ -1 +0,0 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_prewarm;
|
||||
@@ -1,33 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_playlist_tracks_position;
|
||||
DROP INDEX IF EXISTS idx_lyrics_track_id;
|
||||
DROP INDEX IF EXISTS idx_genres_name;
|
||||
DROP INDEX IF EXISTS idx_albums_release_date;
|
||||
DROP INDEX IF EXISTS idx_albums_source;
|
||||
DROP INDEX IF EXISTS idx_albums_upc;
|
||||
DROP INDEX IF EXISTS idx_tracks_source;
|
||||
DROP INDEX IF EXISTS idx_tracks_isrc;
|
||||
DROP INDEX IF EXISTS idx_artists_source;
|
||||
DROP INDEX IF EXISTS idx_artists_name;
|
||||
|
||||
DROP TABLE IF EXISTS track_external_ids;
|
||||
DROP TABLE IF EXISTS album_external_ids;
|
||||
DROP TABLE IF EXISTS artist_external_ids;
|
||||
|
||||
DROP TABLE IF EXISTS playlist_tracks;
|
||||
DROP TABLE IF EXISTS playlists;
|
||||
DROP TABLE IF EXISTS lyrics;
|
||||
|
||||
DROP TABLE IF EXISTS similar_artists;
|
||||
DROP TABLE IF EXISTS album_genres;
|
||||
DROP TABLE IF EXISTS artist_genres;
|
||||
DROP TABLE IF EXISTS work_artists;
|
||||
DROP TABLE IF EXISTS album_tracks;
|
||||
DROP TABLE IF EXISTS album_artists;
|
||||
DROP TABLE IF EXISTS track_artists;
|
||||
|
||||
DROP TABLE IF EXISTS genres;
|
||||
DROP TABLE IF EXISTS albums;
|
||||
DROP TABLE IF EXISTS labels;
|
||||
DROP TABLE IF EXISTS tracks;
|
||||
DROP TABLE IF EXISTS works;
|
||||
DROP TABLE IF EXISTS artists;
|
||||
@@ -1,10 +0,0 @@
|
||||
listen_addresses = '*'
|
||||
shared_preload_libraries = 'pg_prewarm'
|
||||
|
||||
pg_prewarm.autoprewarm = true
|
||||
pg_prewarm.autoprewarm_interval = 300
|
||||
|
||||
shared_buffers = 256MB
|
||||
effective_cache_size = 768MB
|
||||
work_mem = 16MB
|
||||
maintenance_work_mem = 128MB
|
||||
@@ -0,0 +1,17 @@
|
||||
info:
|
||||
name: SearchArtist
|
||||
type: grpc
|
||||
seq: 1
|
||||
|
||||
grpc:
|
||||
url: localhost:50053
|
||||
method: /metadata.v1.MetadataService/SearchArtists
|
||||
methodType: unary
|
||||
message: |-
|
||||
{
|
||||
"query": "ДДТ",
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"provider": 0
|
||||
}
|
||||
auth: inherit
|
||||
@@ -0,0 +1,25 @@
|
||||
opencollection: 1.0.0
|
||||
|
||||
info:
|
||||
name: Metadata Agregator
|
||||
config:
|
||||
proxy:
|
||||
inherit: true
|
||||
config:
|
||||
protocol: http
|
||||
hostname: ""
|
||||
port: ""
|
||||
auth:
|
||||
username: ""
|
||||
password: ""
|
||||
bypassProxy: ""
|
||||
bundled: false
|
||||
extensions:
|
||||
bruno:
|
||||
ignore:
|
||||
- node_modules
|
||||
- .git
|
||||
presets:
|
||||
request:
|
||||
type: grpc
|
||||
url: localhost:50053
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1783461239,
|
||||
"narHash": "sha256-O+rwg3jreOAOrYd0YrNoOsRu+8is6TCPCIBN3qJTJpM=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "6222c499fe28b87665af2bb6c626da64179cee00",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783008725,
|
||||
"narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783345554,
|
||||
"narHash": "sha256-LZhOm4kqjHUnwP4CNHpaqqEVcumGNd1PvOEOad8LkS0=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "6004ea8c229fe9d41b21c6f4c76bf6c2e10771dd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1783279667,
|
||||
"narHash": "sha256-/NAkDSsve+GNM0Bt6tleJdCGfsTlK89nPjkVOzZMo0s=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f205b5574fd0cb7da5b702a2da51507b7f4fdd1b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780220602,
|
||||
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
languages.go.enable = true;
|
||||
|
||||
git-hooks.hooks = {
|
||||
treefmt.enable = true;
|
||||
};
|
||||
|
||||
treefmt = {
|
||||
enable = true;
|
||||
config.programs = {
|
||||
nixfmt.enable = true;
|
||||
gofmt.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
packages = with pkgs; [
|
||||
pre-commit
|
||||
gitleaks
|
||||
plantuml
|
||||
|
||||
gopls
|
||||
gotools
|
||||
go-tools
|
||||
golangci-lint
|
||||
|
||||
buf
|
||||
protoc-gen-go
|
||||
protoc-gen-go-grpc
|
||||
|
||||
grpcurl
|
||||
|
||||
opencode
|
||||
];
|
||||
|
||||
services.postgres = {
|
||||
enable = true;
|
||||
listen_addresses = "localhost";
|
||||
|
||||
initialDatabases = [
|
||||
{
|
||||
name = "metadata_agregator";
|
||||
schema = ./nix/schema/001_schema.sql;
|
||||
}
|
||||
];
|
||||
|
||||
initialScript = ''
|
||||
CREATE USER agregator WITH PASSWORD 'agregator';
|
||||
GRANT ALL PRIVILEGES ON DATABASE metadata_agregator TO agregator;
|
||||
\c metadata_agregator
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO agregator;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO agregator;
|
||||
GRANT ALL ON SCHEMA public TO agregator;
|
||||
'';
|
||||
};
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
inputs:
|
||||
git-hooks:
|
||||
url: github:cachix/git-hooks.nix
|
||||
inputs:
|
||||
nixpkgs:
|
||||
follows: nixpkgs
|
||||
nixpkgs:
|
||||
url: github:cachix/devenv-nixpkgs/rolling
|
||||
treefmt-nix:
|
||||
url: github:numtide/treefmt-nix
|
||||
inputs:
|
||||
nixpkgs:
|
||||
follows: nixpkgs
|
||||
Generated
-134
@@ -1,134 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775087534,
|
||||
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776796298,
|
||||
"narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770073757,
|
||||
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1774748309,
|
||||
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1777326013,
|
||||
"narHash": "sha256-Z+out+ntVqKHsoZdmEM30+84A/and97fv5vouJtev1Q=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "52ffa3b778ab50cc305ff8927aca2b71a0ff50de",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
git-hooks.url = "github:cachix/git-hooks.nix";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-parts,
|
||||
git-hooks,
|
||||
...
|
||||
}@inputs:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
];
|
||||
|
||||
perSystem =
|
||||
{
|
||||
system,
|
||||
...
|
||||
}:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
|
||||
pre-commit-check = git-hooks.lib.${system}.run {
|
||||
src = ./.;
|
||||
hooks = {
|
||||
nixfmt.enable = true;
|
||||
gofmt.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
metadata-server = pkgs.buildGoModule {
|
||||
pname = "metadata-server";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
vendorHash = null;
|
||||
subPackages = [ "cmd/server" ];
|
||||
|
||||
meta = {
|
||||
description = "Music metadata aggregation gRPC service";
|
||||
mainProgram = "server";
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
formatter = pkgs.nixfmt-tree;
|
||||
|
||||
packages = {
|
||||
default = metadata-server;
|
||||
server = metadata-server;
|
||||
};
|
||||
|
||||
checks = {
|
||||
inherit pre-commit-check;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
inherit (pre-commit-check) shellHook;
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
pre-commit
|
||||
gitleaks
|
||||
plantuml
|
||||
|
||||
go
|
||||
gopls
|
||||
gotools
|
||||
go-tools
|
||||
golangci-lint
|
||||
|
||||
buf
|
||||
protoc-gen-go
|
||||
protoc-gen-go-grpc
|
||||
|
||||
grpcurl
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -3,22 +3,36 @@ module github.com/metadata-agregator
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.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/prometheus/client_golang v1.23.2
|
||||
github.com/rs/zerolog v1.35.1
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.35.2
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/protobuf v1.36.8
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.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/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/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=
|
||||
@@ -14,35 +28,72 @@ 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/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
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.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
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/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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=
|
||||
|
||||
@@ -10,12 +10,24 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Metrics MetricsConfig `yaml:"metrics"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"` // "json" or "console"
|
||||
}
|
||||
|
||||
type MetricsConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
@@ -35,6 +47,14 @@ func Load(path string) (*Config, error) {
|
||||
Port: 5432,
|
||||
SSLMode: "disable",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
},
|
||||
Metrics: MetricsConfig{
|
||||
Enabled: true,
|
||||
Port: 9090,
|
||||
},
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
|
||||
+13
-12
@@ -17,18 +17,19 @@ type Artist struct {
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string
|
||||
Title string
|
||||
Type string
|
||||
ReleaseDate *time.Time
|
||||
UPC string
|
||||
TotalTracks int
|
||||
TotalDiscs int
|
||||
CoverURL string
|
||||
Artists []ArtistCredit
|
||||
Label *Label
|
||||
Genres []Genre
|
||||
ExternalIDs []ExternalID
|
||||
ID string
|
||||
Title string
|
||||
Type string
|
||||
SecondaryTypes []string
|
||||
ReleaseDate *time.Time
|
||||
UPC string
|
||||
TotalTracks int
|
||||
TotalDiscs int
|
||||
CoverURL string
|
||||
Artists []ArtistCredit
|
||||
Label *Label
|
||||
Genres []Genre
|
||||
ExternalIDs []ExternalID
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func InterceptorLogger() logging.Logger {
|
||||
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
|
||||
l := zerolog.Ctx(ctx).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 LoggingOpts() []logging.Option {
|
||||
return []logging.Option{
|
||||
logging.WithLogOnEvents(logging.StartCall, logging.FinishCall),
|
||||
logging.WithLevels(levelFunc),
|
||||
logging.WithDurationField(func(d time.Duration) logging.Fields {
|
||||
return logging.Fields{"grpc.duration_ms", d.Milliseconds()}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func levelFunc(code codes.Code) logging.Level {
|
||||
switch code {
|
||||
case codes.OK, codes.NotFound, codes.Canceled:
|
||||
return logging.LevelInfo
|
||||
case codes.InvalidArgument, codes.AlreadyExists, codes.Unauthenticated:
|
||||
return logging.LevelWarn
|
||||
default:
|
||||
return logging.LevelError
|
||||
}
|
||||
}
|
||||
|
||||
func RequestIDInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
requestID := uuid.New().String()
|
||||
|
||||
logger := log.Logger.With().
|
||||
Str("request_id", requestID).
|
||||
Str("grpc.method", info.FullMethod).
|
||||
Logger()
|
||||
|
||||
ctx = logger.WithContext(ctx)
|
||||
|
||||
resp, err := handler(ctx, req)
|
||||
if err != nil {
|
||||
st, _ := status.FromError(err)
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Str("grpc.code", st.Code().String()).
|
||||
Str("grpc.error", st.Message()).
|
||||
Msg("request failed")
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
|
||||
func PeerInfoInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if ok {
|
||||
evt := zerolog.Ctx(ctx).With()
|
||||
if ua := md.Get("user-agent"); len(ua) > 0 {
|
||||
evt = evt.Str("peer.user_agent", ua[0])
|
||||
}
|
||||
if auth := md.Get("authorization"); len(auth) > 0 {
|
||||
evt = evt.Bool("peer.authenticated", true)
|
||||
}
|
||||
logger := evt.Logger()
|
||||
ctx = logger.WithContext(ctx)
|
||||
}
|
||||
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
func StreamRequestIDInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
requestID := uuid.New().String()
|
||||
|
||||
logger := log.Logger.With().
|
||||
Str("request_id", requestID).
|
||||
Str("grpc.method", info.FullMethod).
|
||||
Logger()
|
||||
|
||||
ctx := logger.WithContext(ss.Context())
|
||||
wrapped := &wrappedStream{ServerStream: ss, ctx: ctx}
|
||||
|
||||
return handler(srv, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
type wrappedStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (w *wrappedStream) Context() context.Context {
|
||||
return w.ctx
|
||||
}
|
||||
|
||||
func (w *wrappedStream) SendMsg(m any) error {
|
||||
zerolog.Ctx(w.ctx).Trace().Str("direction", "send").Msg(fmt.Sprintf("stream message: %T", m))
|
||||
return w.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (w *wrappedStream) RecvMsg(m any) error {
|
||||
err := w.ServerStream.RecvMsg(m)
|
||||
if err == nil {
|
||||
zerolog.Ctx(w.ctx).Trace().Str("direction", "recv").Msg(fmt.Sprintf("stream message: %T", m))
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/metadata-agregator/internal/config"
|
||||
)
|
||||
|
||||
func Init(cfg config.LoggingConfig) {
|
||||
level, err := zerolog.ParseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
level = zerolog.InfoLevel
|
||||
}
|
||||
zerolog.SetGlobalLevel(level)
|
||||
|
||||
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).
|
||||
With().Timestamp().Logger()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var (
|
||||
CacheHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "metadata_cache_hits_total",
|
||||
Help: "Number of cache hits by entity type",
|
||||
}, []string{"entity"})
|
||||
|
||||
CacheMisses = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "metadata_cache_misses_total",
|
||||
Help: "Number of cache misses by entity type",
|
||||
}, []string{"entity"})
|
||||
|
||||
ProviderRequests = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "metadata_provider_requests_total",
|
||||
Help: "Number of requests to external providers",
|
||||
}, []string{"provider", "operation", "status"})
|
||||
|
||||
ProviderLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "metadata_provider_request_duration_seconds",
|
||||
Help: "Latency of external provider requests",
|
||||
Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||
}, []string{"provider", "operation"})
|
||||
|
||||
DBQueryLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "metadata_db_query_duration_seconds",
|
||||
Help: "Latency of database queries",
|
||||
Buckets: prometheus.ExponentialBuckets(0.001, 2, 10),
|
||||
}, []string{"operation"})
|
||||
)
|
||||
|
||||
type ServerMetrics struct {
|
||||
grpcMetrics *grpcprom.ServerMetrics
|
||||
registry *prometheus.Registry
|
||||
}
|
||||
|
||||
func NewServerMetrics() *ServerMetrics {
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(prometheus.NewGoCollector())
|
||||
reg.MustRegister(prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}))
|
||||
|
||||
srvMetrics := grpcprom.NewServerMetrics(
|
||||
grpcprom.WithServerHandlingTimeHistogram(
|
||||
grpcprom.WithHistogramBuckets([]float64{
|
||||
0.001, 0.01, 0.05, 0.1, 0.3, 0.6, 1, 3, 6, 10, 30,
|
||||
}),
|
||||
),
|
||||
)
|
||||
reg.MustRegister(srvMetrics)
|
||||
reg.MustRegister(CacheHits)
|
||||
reg.MustRegister(CacheMisses)
|
||||
reg.MustRegister(ProviderRequests)
|
||||
reg.MustRegister(ProviderLatency)
|
||||
reg.MustRegister(DBQueryLatency)
|
||||
|
||||
return &ServerMetrics{
|
||||
grpcMetrics: srvMetrics,
|
||||
registry: reg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ServerMetrics) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
|
||||
return m.grpcMetrics.UnaryServerInterceptor()
|
||||
}
|
||||
|
||||
func (m *ServerMetrics) StreamServerInterceptor() grpc.StreamServerInterceptor {
|
||||
return m.grpcMetrics.StreamServerInterceptor()
|
||||
}
|
||||
|
||||
func (m *ServerMetrics) InitializeMetrics(srv *grpc.Server) {
|
||||
m.grpcMetrics.InitializeMetrics(srv)
|
||||
}
|
||||
|
||||
func (m *ServerMetrics) StartHTTPServer(ctx context.Context, port int) {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{
|
||||
EnableOpenMetrics: true,
|
||||
}))
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Info().Int("port", port).Msg("metrics HTTP server starting")
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error().Err(err).Msg("metrics HTTP server failed")
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,20 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/metadata-agregator/internal/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://musicbrainz.org/ws/2"
|
||||
userAgent = "MetadataAggregator/0.1.0 (https://github.com/metadata-agregator)"
|
||||
baseURL = "https://musicbrainz.org/ws/2"
|
||||
userAgent = "MetadataAggregator/0.1.0 (https://github.com/metadata-agregator)"
|
||||
maxRetries = 3
|
||||
baseBackoff = 2 * time.Second
|
||||
)
|
||||
|
||||
type client struct {
|
||||
@@ -32,10 +38,59 @@ func newClient() *client {
|
||||
}
|
||||
|
||||
func (c *client) get(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, fmt.Errorf("rate limiter: %w", err)
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if err := c.waitForRateLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := c.buildRequest(ctx, endpoint, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := c.executeAndRead(ctx, req, endpoint)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if err != ErrRateLimited {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if attempt < maxRetries {
|
||||
backoff := baseBackoff * time.Duration(1<<attempt)
|
||||
zerolog.Ctx(ctx).Info().
|
||||
Int("attempt", attempt+1).
|
||||
Int("max_retries", maxRetries).
|
||||
Dur("backoff", backoff).
|
||||
Msg("rate limited, waiting before retry")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (c *client) waitForRateLimit(ctx context.Context) error {
|
||||
log := zerolog.Ctx(ctx)
|
||||
log.Trace().Msg("waiting for rate limiter")
|
||||
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
log.Debug().Err(err).Msg("rate limiter interrupted")
|
||||
return fmt.Errorf("rate limiter: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) buildRequest(ctx context.Context, endpoint string, params url.Values) (*http.Request, error) {
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
@@ -50,66 +105,98 @@ func (c *client) get(ctx context.Context, endpoint string, params url.Values) ([
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *client) executeAndRead(ctx context.Context, req *http.Request, endpoint string) ([]byte, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
start := time.Now()
|
||||
|
||||
log.Trace().Str("url", req.URL.String()).Msg("sending HTTP request")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
duration := time.Since(start)
|
||||
log.Debug().Err(err).Str("endpoint", endpoint).Dur("duration", duration).Msg("HTTP request failed")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "error").Inc()
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
duration := time.Since(start)
|
||||
metrics.ProviderLatency.WithLabelValues("musicbrainz", endpoint).Observe(duration.Seconds())
|
||||
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
return c.readResponse(ctx, resp, endpoint, duration)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
func (c *client) readResponse(ctx context.Context, resp *http.Response, endpoint string, duration time.Duration) ([]byte, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
log.Trace().Str("endpoint", endpoint).Dur("duration", duration).Msg("HTTP request succeeded")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "ok").Inc()
|
||||
return io.ReadAll(resp.Body)
|
||||
|
||||
case http.StatusBadRequest:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Warn().Str("endpoint", endpoint).Str("body", string(body)).Dur("duration", duration).Msg("HTTP 400 bad request")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "bad_request").Inc()
|
||||
return nil, fmt.Errorf("%w: %s", ErrBadRequest, extractErrorMessage(body))
|
||||
|
||||
case http.StatusNotFound:
|
||||
log.Debug().Str("endpoint", endpoint).Dur("duration", duration).Msg("HTTP 404")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "not_found").Inc()
|
||||
return nil, ErrNotFound
|
||||
|
||||
case http.StatusServiceUnavailable:
|
||||
log.Warn().Str("endpoint", endpoint).Dur("duration", duration).Msg("HTTP 503 rate limited by provider")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "rate_limited").Inc()
|
||||
return nil, ErrRateLimited
|
||||
|
||||
default:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Warn().Str("endpoint", endpoint).Int("status", resp.StatusCode).Str("body", string(body)).Dur("duration", duration).Msg("unexpected HTTP status")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, fmt.Sprintf("%d", resp.StatusCode)).Inc()
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (c *client) lookup(ctx context.Context, entity, id string, inc []string) ([]byte, error) {
|
||||
zerolog.Ctx(ctx).Debug().Str("entity", entity).Str("id", id).Strs("includes", inc).Msg("provider lookup")
|
||||
|
||||
params := url.Values{}
|
||||
if len(inc) > 0 {
|
||||
incStr := ""
|
||||
for i, v := range inc {
|
||||
if i > 0 {
|
||||
incStr += "+"
|
||||
}
|
||||
incStr += v
|
||||
}
|
||||
params.Set("inc", incStr)
|
||||
if joined := joinIncludes(inc); joined != "" {
|
||||
params.Set("inc", joined)
|
||||
}
|
||||
|
||||
return c.get(ctx, fmt.Sprintf("%s/%s", entity, id), params)
|
||||
}
|
||||
|
||||
func (c *client) browse(ctx context.Context, entity, linkedEntity, linkedID string, limit, offset int, inc []string) ([]byte, error) {
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Str("entity", entity).
|
||||
Str("linked_entity", linkedEntity).
|
||||
Str("linked_id", linkedID).
|
||||
Int("limit", limit).
|
||||
Int("offset", offset).
|
||||
Msg("provider browse")
|
||||
|
||||
params := url.Values{}
|
||||
params.Set(linkedEntity, linkedID)
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
params.Set("offset", fmt.Sprintf("%d", offset))
|
||||
|
||||
if len(inc) > 0 {
|
||||
incStr := ""
|
||||
for i, v := range inc {
|
||||
if i > 0 {
|
||||
incStr += "+"
|
||||
}
|
||||
incStr += v
|
||||
}
|
||||
params.Set("inc", incStr)
|
||||
if joined := joinIncludes(inc); joined != "" {
|
||||
params.Set("inc", joined)
|
||||
}
|
||||
|
||||
return c.get(ctx, entity, params)
|
||||
}
|
||||
|
||||
func (c *client) search(ctx context.Context, entity, query string, limit, offset int) ([]byte, error) {
|
||||
zerolog.Ctx(ctx).Debug().Str("entity", entity).Str("query", query).Int("limit", limit).Int("offset", offset).Msg("provider search")
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
@@ -118,6 +205,23 @@ func (c *client) search(ctx context.Context, entity, query string, limit, offset
|
||||
return c.get(ctx, entity, params)
|
||||
}
|
||||
|
||||
func joinIncludes(inc []string) string {
|
||||
if len(inc) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(inc, "+")
|
||||
}
|
||||
|
||||
func extractErrorMessage(body []byte) string {
|
||||
var errResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
|
||||
return errResp.Error
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func decode[T any](data []byte) (*T, error) {
|
||||
var result T
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
|
||||
@@ -4,5 +4,6 @@ import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrRateLimited = errors.New("rate limited")
|
||||
)
|
||||
|
||||
@@ -61,9 +61,10 @@ func mapAlbum(mb *mbReleaseGroup, release *mbRelease) *domain.Album {
|
||||
}
|
||||
|
||||
album := &domain.Album{
|
||||
ID: mb.ID,
|
||||
Title: mb.Title,
|
||||
Type: mb.PrimaryType,
|
||||
ID: mb.ID,
|
||||
Title: mb.Title,
|
||||
Type: mb.PrimaryType,
|
||||
SecondaryTypes: mb.SecondaryTypes,
|
||||
ExternalIDs: []domain.ExternalID{{
|
||||
Source: "musicbrainz",
|
||||
SourceID: mb.ID,
|
||||
|
||||
@@ -42,8 +42,8 @@ func (p *Provider) SearchArtists(ctx context.Context, query string, limit, offse
|
||||
limit = 25
|
||||
}
|
||||
|
||||
escapedQuery := escapeQuery(query)
|
||||
data, err := p.client.search(ctx, "artist", fmt.Sprintf("artist:%s", escapedQuery), limit, offset)
|
||||
luceneQuery := buildFuzzyArtistQuery(query, 0.7)
|
||||
data, err := p.client.search(ctx, "artist", luceneQuery, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search artists: %w", err)
|
||||
}
|
||||
@@ -134,38 +134,38 @@ func (p *Provider) SearchAlbums(ctx context.Context, query string, artist string
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
func (p *Provider) GetArtistAlbums(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
var allAlbums []domain.Album
|
||||
offset := 0
|
||||
batchSize := 100
|
||||
|
||||
data, err := p.client.browse(ctx, "release-group", "artist", artistID, limit, offset, []string{"artist-credits"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse release-groups: %w", err)
|
||||
}
|
||||
for {
|
||||
data, err := p.client.browse(ctx, "release-group", "artist", artistID, batchSize, offset, []string{"artist-credits"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse release-groups: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ReleaseGroupCount int `json:"release-group-count"`
|
||||
ReleaseGroupOffset int `json:"release-group-offset"`
|
||||
ReleaseGroups []*mbReleaseGroup `json:"release-groups"`
|
||||
}
|
||||
if err := decodeInto(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ReleaseGroupCount int `json:"release-group-count"`
|
||||
ReleaseGroups []*mbReleaseGroup `json:"release-groups"`
|
||||
}
|
||||
if err := decodeInto(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &domain.SearchResult[domain.Album]{
|
||||
Total: resp.ReleaseGroupCount,
|
||||
Limit: limit,
|
||||
Offset: resp.ReleaseGroupOffset,
|
||||
}
|
||||
for _, mb := range resp.ReleaseGroups {
|
||||
if album := mapAlbum(mb, nil); album != nil {
|
||||
allAlbums = append(allAlbums, *album)
|
||||
}
|
||||
}
|
||||
|
||||
for _, mb := range resp.ReleaseGroups {
|
||||
if album := mapAlbum(mb, nil); album != nil {
|
||||
result.Items = append(result.Items, *album)
|
||||
offset += len(resp.ReleaseGroups)
|
||||
if offset >= resp.ReleaseGroupCount || len(resp.ReleaseGroups) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return allAlbums, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetTrack(ctx context.Context, id string) (*domain.Track, error) {
|
||||
@@ -311,6 +311,31 @@ func selectCanonicalRelease(releases []*mbRelease) *mbRelease {
|
||||
return best
|
||||
}
|
||||
|
||||
func buildFuzzyArtistQuery(query string, similarity float64) string {
|
||||
terms := strings.Fields(query)
|
||||
if len(terms) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, term := range terms {
|
||||
escaped := escapeForFuzzy(term)
|
||||
parts = append(parts, fmt.Sprintf("%s~%.1f", escaped, similarity))
|
||||
}
|
||||
|
||||
termQuery := strings.Join(parts, " AND ")
|
||||
return fmt.Sprintf("(artist:(%s) OR alias:(%s))", termQuery, termQuery)
|
||||
}
|
||||
|
||||
func escapeForFuzzy(s string) string {
|
||||
special := []string{`+`, `-`, `&`, `|`, `!`, `(`, `)`, `{`, `}`, `[`, `]`, `^`, `"`, `:`, `/`, `\`}
|
||||
result := s
|
||||
for _, char := range special {
|
||||
result = strings.ReplaceAll(result, char, `\`+char)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func escapeQuery(s string) string {
|
||||
special := []string{`+`, `-`, `&`, `|`, `!`, `(`, `)`, `{`, `}`, `[`, `]`, `^`, `"`, `~`, `*`, `?`, `:`, `/`, `\`}
|
||||
result := s
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package musicbrainz
|
||||
|
||||
type mbArtist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortName string `json:"sort-name"`
|
||||
Type string `json:"type"`
|
||||
Country string `json:"country"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
LifeSpan mbLifeSpan `json:"life-span"`
|
||||
Genres []mbGenre `json:"genres"`
|
||||
Relations []mbRelation `json:"relations"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortName string `json:"sort-name"`
|
||||
Type string `json:"type"`
|
||||
Country string `json:"country"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
LifeSpan mbLifeSpan `json:"life-span"`
|
||||
Genres []mbGenre `json:"genres"`
|
||||
Relations []mbRelation `json:"relations"`
|
||||
}
|
||||
|
||||
type mbLifeSpan struct {
|
||||
@@ -22,6 +22,7 @@ type mbReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
SecondaryTypes []string `json:"secondary-types"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
Genres []mbGenre `json:"genres"`
|
||||
@@ -29,16 +30,16 @@ type mbReleaseGroup struct {
|
||||
}
|
||||
|
||||
type mbRelease struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
Country string `json:"country"`
|
||||
Barcode string `json:"barcode"`
|
||||
LabelInfo []mbLabelInfo `json:"label-info"`
|
||||
Media []mbMedium `json:"media"`
|
||||
ReleaseGroup *mbReleaseGroup `json:"release-group"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
Country string `json:"country"`
|
||||
Barcode string `json:"barcode"`
|
||||
LabelInfo []mbLabelInfo `json:"label-info"`
|
||||
Media []mbMedium `json:"media"`
|
||||
ReleaseGroup *mbReleaseGroup `json:"release-group"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
CoverArtArchive mbCoverArtArchive `json:"cover-art-archive"`
|
||||
}
|
||||
|
||||
@@ -60,10 +61,10 @@ type mbLabel struct {
|
||||
}
|
||||
|
||||
type mbMedium struct {
|
||||
Position int `json:"position"`
|
||||
Format string `json:"format"`
|
||||
TrackCount int `json:"track-count"`
|
||||
Tracks []mbTrack `json:"tracks"`
|
||||
Position int `json:"position"`
|
||||
Format string `json:"format"`
|
||||
TrackCount int `json:"track-count"`
|
||||
Tracks []mbTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type mbTrack struct {
|
||||
|
||||
@@ -14,7 +14,7 @@ type Provider interface {
|
||||
|
||||
GetAlbum(ctx context.Context, id string) (*domain.Album, error)
|
||||
SearchAlbums(ctx context.Context, query string, artist string, limit, offset int) (*domain.SearchResult[domain.Album], error)
|
||||
GetArtistAlbums(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error)
|
||||
GetArtistAlbums(ctx context.Context, artistID string) ([]domain.Album, error)
|
||||
|
||||
GetTrack(ctx context.Context, id string) (*domain.Track, error)
|
||||
GetAlbumTracks(ctx context.Context, albumID string) ([]domain.Track, error)
|
||||
|
||||
@@ -22,8 +22,8 @@ func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
|
||||
|
||||
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*domain.Album, error) {
|
||||
query := `
|
||||
SELECT id, title, album_type, release_date, upc, total_tracks, total_discs,
|
||||
cover_url, source, source_id
|
||||
SELECT id, title, album_type, secondary_types, release_date, upc, total_tracks,
|
||||
total_discs, cover_url, source, source_id
|
||||
FROM albums
|
||||
WHERE id = $1`
|
||||
|
||||
@@ -41,8 +41,8 @@ func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*domain.Album
|
||||
|
||||
func (r *AlbumRepository) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Album, error) {
|
||||
query := `
|
||||
SELECT a.id, a.title, a.album_type, a.release_date, a.upc, a.total_tracks,
|
||||
a.total_discs, a.cover_url, a.source, a.source_id
|
||||
SELECT a.id, a.title, a.album_type, a.secondary_types, a.release_date, a.upc,
|
||||
a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
FROM albums a
|
||||
JOIN album_external_ids e ON a.id = e.album_id
|
||||
WHERE e.source = $1 AND e.source_id = $2`
|
||||
@@ -68,8 +68,8 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string, li
|
||||
WHERE ae.source_id = $1`
|
||||
|
||||
searchQuery := `
|
||||
SELECT DISTINCT a.id, a.title, a.album_type, a.release_date, a.upc,
|
||||
a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
SELECT DISTINCT a.id, a.title, a.album_type, a.secondary_types, a.release_date,
|
||||
a.upc, a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
FROM albums a
|
||||
JOIN album_artists aa ON a.id = aa.album_id
|
||||
JOIN artist_external_ids ae ON aa.artist_id = ae.artist_id
|
||||
@@ -105,6 +105,182 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string, li
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
query := `
|
||||
SELECT DISTINCT a.id, a.title, a.album_type, a.secondary_types, a.release_date,
|
||||
a.upc, a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
FROM albums a
|
||||
JOIN album_artists aa ON a.id = aa.album_id
|
||||
JOIN artist_external_ids ae ON aa.artist_id = ae.artist_id
|
||||
WHERE ae.source_id = $1
|
||||
ORDER BY a.release_date DESC NULLS LAST`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var albums []domain.Album
|
||||
var albumIDs []string
|
||||
albumIndex := make(map[string]int)
|
||||
|
||||
for rows.Next() {
|
||||
album, err := r.scanAlbumFromRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albumIndex[album.ID] = len(albums)
|
||||
albumIDs = append(albumIDs, album.ID)
|
||||
albums = append(albums, *album)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(albums) == 0 {
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
if err := r.loadRelationsBatch(ctx, albums, albumIDs, albumIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) loadRelationsBatch(ctx context.Context, albums []domain.Album, albumIDs []string, albumIndex map[string]int) error {
|
||||
extQuery := `SELECT album_id, source, source_id, url FROM album_external_ids WHERE album_id = ANY($1)`
|
||||
extRows, err := r.pool.Query(ctx, extQuery, albumIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer extRows.Close()
|
||||
|
||||
for extRows.Next() {
|
||||
var albumID string
|
||||
var ext domain.ExternalID
|
||||
var url *string
|
||||
if err := extRows.Scan(&albumID, &ext.Source, &ext.SourceID, &url); err != nil {
|
||||
return err
|
||||
}
|
||||
ext.URL = derefString(url)
|
||||
if idx, ok := albumIndex[albumID]; ok {
|
||||
albums[idx].ExternalIDs = append(albums[idx].ExternalIDs, ext)
|
||||
}
|
||||
}
|
||||
if err := extRows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
artistQuery := `
|
||||
SELECT aa.album_id, a.id, a.name, a.sort_name, a.artist_type, a.country,
|
||||
aa.role, aa.position
|
||||
FROM album_artists aa
|
||||
JOIN artists a ON a.id = aa.artist_id
|
||||
WHERE aa.album_id = ANY($1)
|
||||
ORDER BY aa.album_id, aa.position`
|
||||
|
||||
artistRows, err := r.pool.Query(ctx, artistQuery, albumIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer artistRows.Close()
|
||||
|
||||
for artistRows.Next() {
|
||||
var (
|
||||
albumID string
|
||||
ac domain.ArtistCredit
|
||||
sortName *string
|
||||
artType *string
|
||||
country *string
|
||||
)
|
||||
if err := artistRows.Scan(
|
||||
&albumID, &ac.Artist.ID, &ac.Artist.Name, &sortName, &artType, &country,
|
||||
&ac.Role, &ac.Position,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
ac.Artist.SortName = derefString(sortName)
|
||||
ac.Artist.Type = derefString(artType)
|
||||
ac.Artist.Country = derefString(country)
|
||||
if idx, ok := albumIndex[albumID]; ok {
|
||||
albums[idx].Artists = append(albums[idx].Artists, ac)
|
||||
}
|
||||
}
|
||||
|
||||
return artistRows.Err()
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) SaveAll(ctx context.Context, albums []domain.Album) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for i := range albums {
|
||||
album := &albums[i]
|
||||
|
||||
var source, sourceID string
|
||||
if len(album.ExternalIDs) > 0 {
|
||||
source = album.ExternalIDs[0].Source
|
||||
sourceID = album.ExternalIDs[0].SourceID
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO albums (id, title, album_type, secondary_types, release_date, upc,
|
||||
total_tracks, total_discs, cover_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
album_type = EXCLUDED.album_type,
|
||||
secondary_types = EXCLUDED.secondary_types,
|
||||
release_date = EXCLUDED.release_date,
|
||||
upc = EXCLUDED.upc,
|
||||
total_tracks = EXCLUDED.total_tracks,
|
||||
total_discs = EXCLUDED.total_discs,
|
||||
cover_url = EXCLUDED.cover_url,
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, query,
|
||||
album.ID, album.Title, nullString(album.Type), album.SecondaryTypes,
|
||||
album.ReleaseDate, nullString(album.UPC), album.TotalTracks, album.TotalDiscs,
|
||||
nullString(album.CoverURL), source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ext := range album.ExternalIDs {
|
||||
extQuery := `
|
||||
INSERT INTO album_external_ids (album_id, source, source_id, url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (album_id, source, source_id) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
fetched_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, extQuery, album.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, ac := range album.Artists {
|
||||
artistQuery := `
|
||||
INSERT INTO album_artists (album_id, artist_id, role, position)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (album_id, artist_id, role) DO NOTHING`
|
||||
|
||||
_, err = tx.Exec(ctx, artistQuery, album.ID, ac.Artist.ID, ac.Role, ac.Position)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -119,12 +295,13 @@ func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO albums (id, title, album_type, release_date, upc, total_tracks,
|
||||
total_discs, cover_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO albums (id, title, album_type, secondary_types, release_date, upc,
|
||||
total_tracks, total_discs, cover_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
album_type = EXCLUDED.album_type,
|
||||
secondary_types = EXCLUDED.secondary_types,
|
||||
release_date = EXCLUDED.release_date,
|
||||
upc = EXCLUDED.upc,
|
||||
total_tracks = EXCLUDED.total_tracks,
|
||||
@@ -133,8 +310,8 @@ func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, query,
|
||||
album.ID, album.Title, nullString(album.Type), album.ReleaseDate,
|
||||
nullString(album.UPC), album.TotalTracks, album.TotalDiscs,
|
||||
album.ID, album.Title, nullString(album.Type), album.SecondaryTypes,
|
||||
album.ReleaseDate, nullString(album.UPC), album.TotalTracks, album.TotalDiscs,
|
||||
nullString(album.CoverURL), source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +369,7 @@ func (r *AlbumRepository) scanAlbumRow(row pgx.Row) (*domain.Album, error) {
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&album.ID, &album.Title, &albumType, &releaseDate, &upc,
|
||||
&album.ID, &album.Title, &albumType, &album.SecondaryTypes, &releaseDate, &upc,
|
||||
&totalTracks, &totalDiscs, &coverURL, &source, &sourceID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -218,21 +395,57 @@ func (r *AlbumRepository) scanAlbumRow(row pgx.Row) (*domain.Album, error) {
|
||||
|
||||
func (r *AlbumRepository) loadRelations(ctx context.Context, album *domain.Album) error {
|
||||
extQuery := `SELECT source, source_id, url FROM album_external_ids WHERE album_id = $1`
|
||||
rows, err := r.pool.Query(ctx, extQuery, album.ID)
|
||||
extRows, err := r.pool.Query(ctx, extQuery, album.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer extRows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
for extRows.Next() {
|
||||
var ext domain.ExternalID
|
||||
var url *string
|
||||
if err := rows.Scan(&ext.Source, &ext.SourceID, &url); err != nil {
|
||||
if err := extRows.Scan(&ext.Source, &ext.SourceID, &url); err != nil {
|
||||
return err
|
||||
}
|
||||
ext.URL = derefString(url)
|
||||
album.ExternalIDs = append(album.ExternalIDs, ext)
|
||||
}
|
||||
if err := extRows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
artistQuery := `
|
||||
SELECT a.id, a.name, a.sort_name, a.artist_type, a.country,
|
||||
aa.role, aa.position
|
||||
FROM album_artists aa
|
||||
JOIN artists a ON a.id = aa.artist_id
|
||||
WHERE aa.album_id = $1
|
||||
ORDER BY aa.position`
|
||||
|
||||
artistRows, err := r.pool.Query(ctx, artistQuery, album.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer artistRows.Close()
|
||||
|
||||
for artistRows.Next() {
|
||||
var (
|
||||
ac domain.ArtistCredit
|
||||
sortName *string
|
||||
artType *string
|
||||
country *string
|
||||
)
|
||||
if err := artistRows.Scan(
|
||||
&ac.Artist.ID, &ac.Artist.Name, &sortName, &artType, &country,
|
||||
&ac.Role, &ac.Position,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
ac.Artist.SortName = derefString(sortName)
|
||||
ac.Artist.Type = derefString(artType)
|
||||
ac.Artist.Country = derefString(country)
|
||||
album.Artists = append(album.Artists, ac)
|
||||
}
|
||||
|
||||
return artistRows.Err()
|
||||
}
|
||||
|
||||
@@ -60,23 +60,31 @@ func (r *ArtistRepository) GetByExternalID(ctx context.Context, source, sourceID
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) Search(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error) {
|
||||
countQuery := `SELECT COUNT(*) FROM artists WHERE name ILIKE $1`
|
||||
// Use pg_trgm similarity for fuzzy matching with a threshold of 0.3
|
||||
// Combined with ILIKE for substring matches
|
||||
countQuery := `
|
||||
SELECT COUNT(*) FROM artists
|
||||
WHERE name ILIKE $1 OR similarity(lower(name), lower($2)) > 0.3`
|
||||
searchQuery := `
|
||||
SELECT id, name, sort_name, artist_type, country, formed_date, disbanded_date,
|
||||
description, image_url, source, source_id
|
||||
description, image_url, source, source_id,
|
||||
GREATEST(
|
||||
similarity(lower(name), lower($2)),
|
||||
CASE WHEN name ILIKE $1 THEN 0.5 ELSE 0 END
|
||||
) as score
|
||||
FROM artists
|
||||
WHERE name ILIKE $1
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3`
|
||||
WHERE name ILIKE $1 OR similarity(lower(name), lower($2)) > 0.3
|
||||
ORDER BY score DESC, name
|
||||
LIMIT $3 OFFSET $4`
|
||||
|
||||
pattern := "%" + query + "%"
|
||||
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx, countQuery, pattern).Scan(&total); err != nil {
|
||||
if err := r.pool.QueryRow(ctx, countQuery, pattern, query).Scan(&total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, searchQuery, pattern, limit, offset)
|
||||
rows, err := r.pool.Query(ctx, searchQuery, pattern, query, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,7 +92,7 @@ func (r *ArtistRepository) Search(ctx context.Context, query string, limit, offs
|
||||
|
||||
var artists []domain.Artist
|
||||
for rows.Next() {
|
||||
artist, err := r.scanArtistFromRow(rows)
|
||||
artist, err := r.scanArtistFromRowWithScore(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -152,20 +160,79 @@ func (r *ArtistRepository) Save(ctx context.Context, artist *domain.Artist) erro
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) SaveAll(ctx context.Context, artists []domain.Artist) error {
|
||||
if len(artists) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for _, artist := range artists {
|
||||
var source, sourceID string
|
||||
if len(artist.ExternalIDs) > 0 {
|
||||
source = artist.ExternalIDs[0].Source
|
||||
sourceID = artist.ExternalIDs[0].SourceID
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO artists (id, name, sort_name, artist_type, country, formed_date,
|
||||
disbanded_date, description, image_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
sort_name = EXCLUDED.sort_name,
|
||||
artist_type = EXCLUDED.artist_type,
|
||||
country = EXCLUDED.country,
|
||||
formed_date = EXCLUDED.formed_date,
|
||||
disbanded_date = EXCLUDED.disbanded_date,
|
||||
description = EXCLUDED.description,
|
||||
image_url = EXCLUDED.image_url,
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, query,
|
||||
artist.ID, artist.Name, nullString(artist.SortName), nullString(artist.Type),
|
||||
nullString(artist.Country), artist.FormedDate, artist.DisbandedDate,
|
||||
nullString(artist.Description), nullString(artist.ImageURL), source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ext := range artist.ExternalIDs {
|
||||
extQuery := `
|
||||
INSERT INTO artist_external_ids (artist_id, source, source_id, url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (artist_id, source, source_id) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
fetched_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, extQuery, artist.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) scanArtist(ctx context.Context, query string, args ...any) (*domain.Artist, error) {
|
||||
row := r.pool.QueryRow(ctx, query, args...)
|
||||
|
||||
var (
|
||||
artist domain.Artist
|
||||
sortName *string
|
||||
artistType *string
|
||||
country *string
|
||||
formedDate *time.Time
|
||||
disbandDate *time.Time
|
||||
description *string
|
||||
imageURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
artist domain.Artist
|
||||
sortName *string
|
||||
artistType *string
|
||||
country *string
|
||||
formedDate *time.Time
|
||||
disbandDate *time.Time
|
||||
description *string
|
||||
imageURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
@@ -192,16 +259,16 @@ func (r *ArtistRepository) scanArtist(ctx context.Context, query string, args ..
|
||||
|
||||
func (r *ArtistRepository) scanArtistFromRow(row pgx.Row) (*domain.Artist, error) {
|
||||
var (
|
||||
artist domain.Artist
|
||||
sortName *string
|
||||
artistType *string
|
||||
country *string
|
||||
formedDate *time.Time
|
||||
disbandDate *time.Time
|
||||
description *string
|
||||
imageURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
artist domain.Artist
|
||||
sortName *string
|
||||
artistType *string
|
||||
country *string
|
||||
formedDate *time.Time
|
||||
disbandDate *time.Time
|
||||
description *string
|
||||
imageURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
@@ -223,6 +290,40 @@ func (r *ArtistRepository) scanArtistFromRow(row pgx.Row) (*domain.Artist, error
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) scanArtistFromRowWithScore(row pgx.Row) (*domain.Artist, error) {
|
||||
var (
|
||||
artist domain.Artist
|
||||
sortName *string
|
||||
artistType *string
|
||||
country *string
|
||||
formedDate *time.Time
|
||||
disbandDate *time.Time
|
||||
description *string
|
||||
imageURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
score float64
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&artist.ID, &artist.Name, &sortName, &artistType, &country,
|
||||
&formedDate, &disbandDate, &description, &imageURL, &source, &sourceID, &score,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artist.SortName = derefString(sortName)
|
||||
artist.Type = derefString(artistType)
|
||||
artist.Country = derefString(country)
|
||||
artist.FormedDate = formedDate
|
||||
artist.DisbandedDate = disbandDate
|
||||
artist.Description = derefString(description)
|
||||
artist.ImageURL = derefString(imageURL)
|
||||
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) loadExternalIDs(ctx context.Context, artist *domain.Artist) error {
|
||||
query := `SELECT source, source_id, url FROM artist_external_ids WHERE artist_id = $1`
|
||||
|
||||
|
||||
@@ -170,6 +170,67 @@ func (r *TrackRepository) Save(ctx context.Context, track *domain.Track) error {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *TrackRepository) SaveAlbumTracks(ctx context.Context, albumID string, tracks []domain.Track) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for i := range tracks {
|
||||
track := &tracks[i]
|
||||
|
||||
var source, sourceID string
|
||||
if len(track.ExternalIDs) > 0 {
|
||||
source = track.ExternalIDs[0].Source
|
||||
sourceID = track.ExternalIDs[0].SourceID
|
||||
}
|
||||
|
||||
trackQuery := `
|
||||
INSERT INTO tracks (id, title, duration_ms, isrc, explicit, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
duration_ms = EXCLUDED.duration_ms,
|
||||
isrc = EXCLUDED.isrc,
|
||||
explicit = EXCLUDED.explicit,
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, trackQuery,
|
||||
track.ID, track.Title, track.DurationMs, nullString(track.ISRC),
|
||||
track.Explicit, source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ext := range track.ExternalIDs {
|
||||
extQuery := `
|
||||
INSERT INTO track_external_ids (track_id, source, source_id, url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (track_id, source, source_id) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
fetched_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, extQuery, track.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
linkQuery := `
|
||||
INSERT INTO album_tracks (album_id, track_id, disc_number, track_number)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (album_id, track_id) DO NOTHING`
|
||||
|
||||
_, err = tx.Exec(ctx, linkQuery, albumID, track.ID, track.DiscNumber, track.TrackNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *TrackRepository) scanTrack(ctx context.Context, query string, args ...any) (*domain.Track, error) {
|
||||
row := r.pool.QueryRow(ctx, query, args...)
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@ type ArtistRepository interface {
|
||||
GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Artist, error)
|
||||
Search(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error)
|
||||
Save(ctx context.Context, artist *domain.Artist) error
|
||||
SaveAll(ctx context.Context, artists []domain.Artist) error
|
||||
}
|
||||
|
||||
type AlbumRepository interface {
|
||||
GetByID(ctx context.Context, id string) (*domain.Album, error)
|
||||
GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Album, error)
|
||||
GetByArtistID(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error)
|
||||
GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error)
|
||||
Save(ctx context.Context, album *domain.Album) error
|
||||
SaveAll(ctx context.Context, albums []domain.Album) error
|
||||
}
|
||||
|
||||
type TrackRepository interface {
|
||||
@@ -26,4 +29,5 @@ type TrackRepository interface {
|
||||
GetByISRC(ctx context.Context, isrc string) (*domain.Track, error)
|
||||
GetByAlbumID(ctx context.Context, albumID string) ([]domain.Track, error)
|
||||
Save(ctx context.Context, track *domain.Track) error
|
||||
SaveAlbumTracks(ctx context.Context, albumID string, tracks []domain.Track) error
|
||||
}
|
||||
|
||||
+115
-32
@@ -3,10 +3,13 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/provider/musicbrainz"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
"github.com/metadata-agregator/internal/service"
|
||||
@@ -22,21 +25,26 @@ func NewMetadataServer(services map[metadatav1.Provider]*service.MetadataService
|
||||
return &MetadataServer{services: services}
|
||||
}
|
||||
|
||||
func (s *MetadataServer) getService(p metadatav1.Provider) (*service.MetadataService, error) {
|
||||
func (s *MetadataServer) getService(ctx context.Context, p metadatav1.Provider) (*service.MetadataService, error) {
|
||||
if p == metadatav1.Provider_PROVIDER_UNSPECIFIED {
|
||||
p = metadatav1.Provider_PROVIDER_MUSICBRAINZ
|
||||
}
|
||||
|
||||
zerolog.Ctx(ctx).Debug().Str("provider", p.String()).Msg("resolved provider")
|
||||
|
||||
svc, ok := s.services[p]
|
||||
if !ok {
|
||||
zerolog.Ctx(ctx).Warn().Str("provider", p.String()).Msg("unknown provider requested")
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unknown provider: %v", p)
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetArtist(ctx context.Context, req *metadatav1.GetArtistRequest) (*metadatav1.Artist, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
func (s *MetadataServer) GetArtist(ctx context.Context, req *metadatav1.GetArtistRequest) (*metadatav1.GetArtistResponse, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
svc, err := s.getService(ctx, req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,22 +53,28 @@ func (s *MetadataServer) GetArtist(ctx context.Context, req *metadatav1.GetArtis
|
||||
switch v := req.Identifier.(type) {
|
||||
case *metadatav1.GetArtistRequest_Id:
|
||||
id = v.Id
|
||||
log.Debug().Str("lookup", "id").Str("artist_id", id).Msg("getting artist")
|
||||
case *metadatav1.GetArtistRequest_External:
|
||||
id = v.External.SourceId
|
||||
log.Debug().Str("lookup", "external").Str("source_id", id).Str("source", v.External.Source).Msg("getting artist")
|
||||
default:
|
||||
log.Warn().Msg("get artist called without identifier")
|
||||
return nil, status.Error(codes.InvalidArgument, "identifier required")
|
||||
}
|
||||
|
||||
artist, err := svc.GetArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
|
||||
return toProtoArtist(artist), nil
|
||||
log.Trace().Str("artist_id", artist.ID).Str("name", artist.Name).Msg("artist found")
|
||||
return &metadatav1.GetArtistResponse{Artist: toProtoArtist(artist)}, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) SearchArtists(ctx context.Context, req *metadatav1.SearchArtistsRequest) (*metadatav1.SearchArtistsResponse, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
svc, err := s.getService(ctx, req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,9 +84,11 @@ func (s *MetadataServer) SearchArtists(ctx context.Context, req *metadatav1.Sear
|
||||
limit = 25
|
||||
}
|
||||
|
||||
log.Debug().Str("query", req.Query).Int("limit", limit).Int("offset", int(req.Offset)).Msg("searching artists")
|
||||
|
||||
result, err := svc.SearchArtists(ctx, req.Query, limit, int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
|
||||
resp := &metadatav1.SearchArtistsResponse{
|
||||
@@ -83,11 +99,14 @@ func (s *MetadataServer) SearchArtists(ctx context.Context, req *metadatav1.Sear
|
||||
resp.Artists = append(resp.Artists, toProtoArtist(&a))
|
||||
}
|
||||
|
||||
log.Trace().Int("total", result.Total).Int("returned", len(resp.Artists)).Msg("artist search complete")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) SearchAlbums(ctx context.Context, req *metadatav1.SearchAlbumsRequest) (*metadatav1.SearchAlbumsResponse, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
svc, err := s.getService(ctx, req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -97,9 +116,11 @@ func (s *MetadataServer) SearchAlbums(ctx context.Context, req *metadatav1.Searc
|
||||
limit = 25
|
||||
}
|
||||
|
||||
log.Debug().Str("query", req.Query).Str("artist", req.Artist).Int("limit", limit).Int("offset", int(req.Offset)).Msg("searching albums")
|
||||
|
||||
result, err := svc.SearchAlbums(ctx, req.Query, req.Artist, limit, int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
|
||||
resp := &metadatav1.SearchAlbumsResponse{
|
||||
@@ -110,11 +131,14 @@ func (s *MetadataServer) SearchAlbums(ctx context.Context, req *metadatav1.Searc
|
||||
resp.Albums = append(resp.Albums, toProtoAlbum(&a))
|
||||
}
|
||||
|
||||
log.Trace().Int("total", result.Total).Int("returned", len(resp.Albums)).Msg("album search complete")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetAlbum(ctx context.Context, req *metadatav1.GetAlbumRequest) (*metadatav1.Album, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
func (s *MetadataServer) GetAlbum(ctx context.Context, req *metadatav1.GetAlbumRequest) (*metadatav1.GetAlbumResponse, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
svc, err := s.getService(ctx, req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -123,49 +147,58 @@ func (s *MetadataServer) GetAlbum(ctx context.Context, req *metadatav1.GetAlbumR
|
||||
switch v := req.Identifier.(type) {
|
||||
case *metadatav1.GetAlbumRequest_Id:
|
||||
id = v.Id
|
||||
log.Debug().Str("lookup", "id").Str("album_id", id).Msg("getting album")
|
||||
case *metadatav1.GetAlbumRequest_External:
|
||||
id = v.External.SourceId
|
||||
log.Debug().Str("lookup", "external").Str("source_id", id).Str("source", v.External.Source).Msg("getting album")
|
||||
default:
|
||||
log.Warn().Msg("get album called without identifier")
|
||||
return nil, status.Error(codes.InvalidArgument, "identifier required")
|
||||
}
|
||||
|
||||
album, err := svc.GetAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
|
||||
return toProtoAlbum(album), nil
|
||||
log.Trace().Str("album_id", album.ID).Str("title", album.Title).Msg("album found")
|
||||
return &metadatav1.GetAlbumResponse{Album: toProtoAlbum(album)}, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetArtistAlbums(ctx context.Context, req *metadatav1.GetArtistAlbumsRequest) (*metadatav1.GetArtistAlbumsResponse, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
svc, err := s.getService(ctx, req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := int(req.Limit)
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
albumTypes := defaultAlbumTypes(req.AlbumTypes)
|
||||
log.Debug().Str("artist_id", req.ArtistId).Strs("album_types", albumTypes).Msg("getting artist albums")
|
||||
|
||||
allAlbums, err := svc.GetArtistAlbums(ctx, req.ArtistId)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
|
||||
result, err := svc.GetArtistAlbums(ctx, req.ArtistId, limit, int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
filtered := filterAlbums(allAlbums, albumTypes)
|
||||
|
||||
resp := &metadatav1.GetArtistAlbumsResponse{
|
||||
Total: int32(result.Total),
|
||||
Total: int32(len(filtered)),
|
||||
}
|
||||
|
||||
for _, a := range result.Items {
|
||||
for _, a := range filtered {
|
||||
resp.Albums = append(resp.Albums, toProtoAlbum(&a))
|
||||
}
|
||||
|
||||
log.Trace().Int("total_fetched", len(allAlbums)).Int("filtered", len(filtered)).Strs("album_types", albumTypes).Msg("artist albums retrieved")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetTrack(ctx context.Context, req *metadatav1.GetTrackRequest) (*metadatav1.Track, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
func (s *MetadataServer) GetTrack(ctx context.Context, req *metadatav1.GetTrackRequest) (*metadatav1.GetTrackResponse, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
svc, err := s.getService(ctx, req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,42 +207,51 @@ func (s *MetadataServer) GetTrack(ctx context.Context, req *metadatav1.GetTrackR
|
||||
|
||||
switch v := req.Identifier.(type) {
|
||||
case *metadatav1.GetTrackRequest_Id:
|
||||
log.Debug().Str("lookup", "id").Str("track_id", v.Id).Msg("getting track")
|
||||
t, err := svc.GetTrack(ctx, v.Id)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
track = toProtoTrack(t)
|
||||
|
||||
case *metadatav1.GetTrackRequest_External:
|
||||
log.Debug().Str("lookup", "external").Str("source_id", v.External.SourceId).Str("source", v.External.Source).Msg("getting track")
|
||||
t, err := svc.GetTrack(ctx, v.External.SourceId)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
track = toProtoTrack(t)
|
||||
|
||||
case *metadatav1.GetTrackRequest_Isrc:
|
||||
log.Debug().Str("lookup", "isrc").Str("isrc", v.Isrc).Msg("getting track")
|
||||
t, err := svc.GetTrackByISRC(ctx, v.Isrc)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
track = toProtoTrack(t)
|
||||
|
||||
default:
|
||||
log.Warn().Msg("get track called without identifier")
|
||||
return nil, status.Error(codes.InvalidArgument, "identifier required")
|
||||
}
|
||||
|
||||
return track, nil
|
||||
log.Trace().Str("track_id", track.Id).Str("title", track.Title).Msg("track found")
|
||||
return &metadatav1.GetTrackResponse{Track: track}, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetAlbumTracks(ctx context.Context, req *metadatav1.GetAlbumTracksRequest) (*metadatav1.GetAlbumTracksResponse, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
svc, err := s.getService(ctx, req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug().Str("album_id", req.AlbumId).Msg("getting album tracks")
|
||||
|
||||
tracks, err := svc.GetAlbumTracks(ctx, req.AlbumId)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
|
||||
resp := &metadatav1.GetAlbumTracksResponse{}
|
||||
@@ -217,29 +259,70 @@ func (s *MetadataServer) GetAlbumTracks(ctx context.Context, req *metadatav1.Get
|
||||
resp.Tracks = append(resp.Tracks, toProtoTrack(&t))
|
||||
}
|
||||
|
||||
log.Trace().Int("track_count", len(resp.Tracks)).Msg("album tracks retrieved")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) SyncArtist(ctx context.Context, req *metadatav1.SyncArtistRequest) (*metadatav1.SyncArtistResponse, error) {
|
||||
zerolog.Ctx(ctx).Warn().Msg("sync artist called but not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "sync not yet implemented")
|
||||
}
|
||||
|
||||
func toGRPCError(err error) error {
|
||||
var defaultTypes = []string{"album", "ep", "single"}
|
||||
|
||||
func defaultAlbumTypes(types []string) []string {
|
||||
if len(types) > 0 {
|
||||
return types
|
||||
}
|
||||
return defaultTypes
|
||||
}
|
||||
|
||||
func filterAlbums(albums []domain.Album, types []string) []domain.Album {
|
||||
allowed := make(map[string]bool, len(types))
|
||||
for _, t := range types {
|
||||
allowed[strings.ToLower(t)] = true
|
||||
}
|
||||
|
||||
var result []domain.Album
|
||||
for _, a := range albums {
|
||||
if len(a.SecondaryTypes) > 0 {
|
||||
continue
|
||||
}
|
||||
if !allowed[strings.ToLower(a.Type)] {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toGRPCError(ctx context.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
if errors.Is(err, musicbrainz.ErrBadRequest) {
|
||||
log.Debug().Err(err).Msg("bad request")
|
||||
return status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
log.Debug().Msg("entity not found")
|
||||
return status.Error(codes.NotFound, "not found")
|
||||
}
|
||||
|
||||
if errors.Is(err, musicbrainz.ErrNotFound) {
|
||||
log.Debug().Msg("provider entity not found")
|
||||
return status.Error(codes.NotFound, "not found")
|
||||
}
|
||||
|
||||
if errors.Is(err, musicbrainz.ErrRateLimited) {
|
||||
log.Warn().Msg("provider rate limited")
|
||||
return status.Error(codes.ResourceExhausted, "rate limited")
|
||||
}
|
||||
|
||||
log.Error().Err(err).Msg("internal error")
|
||||
return status.Errorf(codes.Internal, "internal error: %v", err)
|
||||
}
|
||||
|
||||
+242
-17
@@ -3,8 +3,12 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/metrics"
|
||||
"github.com/metadata-agregator/internal/provider"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
)
|
||||
@@ -31,116 +35,337 @@ func NewMetadataService(
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetArtist(ctx context.Context, id string) (*domain.Artist, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
artist, err := s.artists.GetByExternalID(ctx, s.provider.Name(), id)
|
||||
if err == nil {
|
||||
log.Debug().Str("artist_id", id).Msg("artist cache hit")
|
||||
metrics.CacheHits.WithLabelValues("artist").Inc()
|
||||
return artist, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
log.Error().Err(err).Str("artist_id", id).Msg("artist cache lookup failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics.CacheMisses.WithLabelValues("artist").Inc()
|
||||
log.Debug().Str("artist_id", id).Str("provider", s.provider.Name()).Msg("artist cache miss, fetching from provider")
|
||||
|
||||
artist, err = s.provider.GetArtist(ctx, id)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("artist_id", id).Msg("provider fetch failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveErr := s.artists.Save(ctx, artist); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("artist_id", id).Msg("failed to cache artist")
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
log.Trace().Str("artist_id", id).Msg("artist cached successfully")
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) SearchArtists(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error) {
|
||||
result, err := s.artists.Search(ctx, query, limit, offset)
|
||||
if err == nil && len(result.Items) > 0 {
|
||||
return result, nil
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
dbResult *domain.SearchResult[domain.Artist]
|
||||
mbResult *domain.SearchResult[domain.Artist]
|
||||
dbErr error
|
||||
mbErr error
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dbResult, dbErr = s.artists.Search(ctx, query, limit, offset)
|
||||
if dbErr == nil && len(dbResult.Items) > 0 {
|
||||
metrics.CacheHits.WithLabelValues("artist_search").Inc()
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mbResult, mbErr = s.provider.SearchArtists(ctx, query, limit, offset)
|
||||
if mbErr == nil && len(mbResult.Items) > 0 {
|
||||
if saveErr := s.artists.SaveAll(ctx, mbResult.Items); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Msg("failed to cache provider search results")
|
||||
} else {
|
||||
log.Debug().Int("count", len(mbResult.Items)).Msg("cached provider search results")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if dbErr != nil {
|
||||
log.Warn().Err(dbErr).Msg("database search failed")
|
||||
}
|
||||
if mbErr != nil {
|
||||
log.Warn().Err(mbErr).Msg("provider search failed")
|
||||
}
|
||||
|
||||
return s.provider.SearchArtists(ctx, query, limit, offset)
|
||||
if dbErr != nil && mbErr != nil {
|
||||
return nil, mbErr
|
||||
}
|
||||
|
||||
combined := s.deduplicateArtists(dbResult, mbResult)
|
||||
log.Debug().
|
||||
Int("db_count", countItems(dbResult)).
|
||||
Int("provider_count", countItems(mbResult)).
|
||||
Int("combined_count", len(combined.Items)).
|
||||
Msg("artist search completed")
|
||||
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) deduplicateArtists(dbResult, mbResult *domain.SearchResult[domain.Artist]) *domain.SearchResult[domain.Artist] {
|
||||
seen := make(map[string]bool)
|
||||
var items []domain.Artist
|
||||
total := 0
|
||||
|
||||
addArtist := func(artist domain.Artist) {
|
||||
if artist.Type == "" || artist.Type == "Other" {
|
||||
return
|
||||
}
|
||||
for _, ext := range artist.ExternalIDs {
|
||||
key := ext.Source + ":" + ext.SourceID
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, ext := range artist.ExternalIDs {
|
||||
seen[ext.Source+":"+ext.SourceID] = true
|
||||
}
|
||||
items = append(items, artist)
|
||||
}
|
||||
|
||||
if dbResult != nil {
|
||||
for _, artist := range dbResult.Items {
|
||||
addArtist(artist)
|
||||
}
|
||||
total = dbResult.Total
|
||||
}
|
||||
|
||||
if mbResult != nil {
|
||||
for _, artist := range mbResult.Items {
|
||||
addArtist(artist)
|
||||
}
|
||||
if mbResult.Total > total {
|
||||
total = mbResult.Total
|
||||
}
|
||||
}
|
||||
|
||||
limit := 25
|
||||
offset := 0
|
||||
if dbResult != nil {
|
||||
limit = dbResult.Limit
|
||||
offset = dbResult.Offset
|
||||
} else if mbResult != nil {
|
||||
limit = mbResult.Limit
|
||||
offset = mbResult.Offset
|
||||
}
|
||||
|
||||
return &domain.SearchResult[domain.Artist]{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
func countItems[T any](result *domain.SearchResult[T]) int {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
return len(result.Items)
|
||||
}
|
||||
|
||||
func (s *MetadataService) SearchAlbums(ctx context.Context, query string, artist string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
zerolog.Ctx(ctx).Debug().Str("query", query).Str("artist", artist).Str("provider", s.provider.Name()).Msg("searching albums via provider")
|
||||
return s.provider.SearchAlbums(ctx, query, artist, limit, offset)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetAlbum(ctx context.Context, id string) (*domain.Album, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
album, err := s.albums.GetByExternalID(ctx, s.provider.Name(), id)
|
||||
if err == nil {
|
||||
log.Debug().Str("album_id", id).Msg("album cache hit")
|
||||
metrics.CacheHits.WithLabelValues("album").Inc()
|
||||
return album, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
log.Error().Err(err).Str("album_id", id).Msg("album cache lookup failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics.CacheMisses.WithLabelValues("album").Inc()
|
||||
log.Debug().Str("album_id", id).Str("provider", s.provider.Name()).Msg("album cache miss, fetching from provider")
|
||||
|
||||
album, err = s.provider.GetAlbum(ctx, id)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("album_id", id).Msg("provider fetch failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.ensureArtistsCached(ctx, []domain.Album{*album})
|
||||
|
||||
if saveErr := s.albums.Save(ctx, album); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("album_id", id).Msg("failed to cache album")
|
||||
return album, nil
|
||||
}
|
||||
|
||||
log.Trace().Str("album_id", id).Msg("album cached successfully")
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
result, err := s.albums.GetByArtistID(ctx, artistID, limit, offset)
|
||||
if err == nil && len(result.Items) > 0 {
|
||||
return result, nil
|
||||
func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
cached, err := s.albums.GetAllByArtistID(ctx, artistID)
|
||||
if err == nil && len(cached) > 0 {
|
||||
log.Debug().Str("artist_id", artistID).Int("count", len(cached)).Msg("artist albums cache hit")
|
||||
metrics.CacheHits.WithLabelValues("artist_albums").Inc()
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
return s.provider.GetArtistAlbums(ctx, artistID, limit, offset)
|
||||
}
|
||||
metrics.CacheMisses.WithLabelValues("artist_albums").Inc()
|
||||
log.Debug().Str("artist_id", artistID).Str("provider", s.provider.Name()).Msg("artist albums cache miss, fetching from provider")
|
||||
|
||||
func (s *MetadataService) GetTrack(ctx context.Context, id string) (*domain.Track, error) {
|
||||
track, err := s.tracks.GetByExternalID(ctx, s.provider.Name(), id)
|
||||
if err == nil {
|
||||
return track, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
albums, err := s.provider.GetArtistAlbums(ctx, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.ensureArtistsCached(ctx, albums)
|
||||
|
||||
if saveErr := s.albums.SaveAll(ctx, albums); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("artist_id", artistID).Msg("failed to cache artist albums")
|
||||
} else {
|
||||
log.Debug().Str("artist_id", artistID).Int("count", len(albums)).Msg("artist albums cached")
|
||||
}
|
||||
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) ensureArtistsCached(ctx context.Context, albums []domain.Album) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, album := range albums {
|
||||
for _, ac := range album.Artists {
|
||||
id := ac.Artist.ID
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
_, err := s.artists.GetByID(ctx, id)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := s.GetArtist(ctx, id); err != nil {
|
||||
log.Warn().Err(err).Str("artist_id", id).Msg("failed to fetch and cache artist from album credit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetTrack(ctx context.Context, id string) (*domain.Track, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
track, err := s.tracks.GetByExternalID(ctx, s.provider.Name(), id)
|
||||
if err == nil {
|
||||
log.Debug().Str("track_id", id).Msg("track cache hit")
|
||||
metrics.CacheHits.WithLabelValues("track").Inc()
|
||||
return track, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
log.Error().Err(err).Str("track_id", id).Msg("track cache lookup failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics.CacheMisses.WithLabelValues("track").Inc()
|
||||
log.Debug().Str("track_id", id).Str("provider", s.provider.Name()).Msg("track cache miss, fetching from provider")
|
||||
|
||||
track, err = s.provider.GetTrack(ctx, id)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("track_id", id).Msg("provider fetch failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveErr := s.tracks.Save(ctx, track); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("track_id", id).Msg("failed to cache track")
|
||||
return track, nil
|
||||
}
|
||||
|
||||
log.Trace().Str("track_id", id).Msg("track cached successfully")
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetTrackByISRC(ctx context.Context, isrc string) (*domain.Track, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
track, err := s.tracks.GetByISRC(ctx, isrc)
|
||||
if err == nil {
|
||||
log.Debug().Str("isrc", isrc).Msg("track ISRC cache hit")
|
||||
metrics.CacheHits.WithLabelValues("track_isrc").Inc()
|
||||
return track, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
log.Error().Err(err).Str("isrc", isrc).Msg("track ISRC cache lookup failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics.CacheMisses.WithLabelValues("track_isrc").Inc()
|
||||
log.Debug().Str("isrc", isrc).Str("provider", s.provider.Name()).Msg("track ISRC cache miss, fetching from provider")
|
||||
|
||||
track, err = s.provider.GetTrackByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("isrc", isrc).Msg("provider ISRC fetch failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveErr := s.tracks.Save(ctx, track); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("isrc", isrc).Msg("failed to cache track")
|
||||
return track, nil
|
||||
}
|
||||
|
||||
log.Trace().Str("isrc", isrc).Msg("track cached successfully")
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetAlbumTracks(ctx context.Context, albumID string) ([]domain.Track, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
tracks, err := s.tracks.GetByAlbumID(ctx, albumID)
|
||||
if err == nil && len(tracks) > 0 {
|
||||
log.Debug().Str("album_id", albumID).Int("count", len(tracks)).Msg("album tracks served from cache")
|
||||
metrics.CacheHits.WithLabelValues("album_tracks").Inc()
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
return s.provider.GetAlbumTracks(ctx, albumID)
|
||||
metrics.CacheMisses.WithLabelValues("album_tracks").Inc()
|
||||
log.Debug().Str("album_id", albumID).Str("provider", s.provider.Name()).Msg("album tracks cache miss, querying provider")
|
||||
|
||||
tracks, err = s.provider.GetAlbumTracks(ctx, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
album, err := s.albums.GetByExternalID(ctx, s.provider.Name(), albumID)
|
||||
if err == nil {
|
||||
if saveErr := s.tracks.SaveAlbumTracks(ctx, album.ID, tracks); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("album_id", albumID).Msg("failed to cache album tracks")
|
||||
} else {
|
||||
log.Debug().Str("album_id", albumID).Int("count", len(tracks)).Msg("album tracks cached")
|
||||
}
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.metadata-agregator;
|
||||
yaml = pkgs.formats.yaml { };
|
||||
|
||||
# Build the DSN from database options, password injected at runtime
|
||||
dbPasswordPlaceholder = "@DB_PASSWORD@";
|
||||
|
||||
configFile = yaml.generate "metadata-agregator.yaml" {
|
||||
server = {
|
||||
port = cfg.server.port;
|
||||
};
|
||||
database = {
|
||||
host = cfg.database.host;
|
||||
port = cfg.database.port;
|
||||
name = cfg.database.name;
|
||||
user = cfg.database.user;
|
||||
password = dbPasswordPlaceholder;
|
||||
sslmode = cfg.database.sslmode;
|
||||
};
|
||||
logging = {
|
||||
level = cfg.logging.level;
|
||||
format = cfg.logging.format;
|
||||
};
|
||||
metrics = {
|
||||
enabled = cfg.metrics.enable;
|
||||
port = cfg.metrics.port;
|
||||
};
|
||||
};
|
||||
|
||||
schemaFile = ./schema/001_schema.sql;
|
||||
in
|
||||
{
|
||||
options.services.metadata-agregator = {
|
||||
enable = lib.mkEnableOption "metadata-agregator music metadata gRPC service";
|
||||
|
||||
package = lib.mkPackageOption pkgs "metadata-agregator" {
|
||||
default = null;
|
||||
};
|
||||
|
||||
server = {
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 50051;
|
||||
description = "gRPC listen port.";
|
||||
};
|
||||
};
|
||||
|
||||
database = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "localhost";
|
||||
description = "PostgreSQL host.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 5432;
|
||||
description = "PostgreSQL port.";
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "metadata_agregator";
|
||||
description = "Database name.";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "metadata_agregator";
|
||||
description = "Database user.";
|
||||
};
|
||||
|
||||
passwordFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
File containing the database password.
|
||||
The file content is read at service start and injected into the config.
|
||||
Set to null for peer authentication (unix socket).
|
||||
'';
|
||||
};
|
||||
|
||||
sslmode = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"disable"
|
||||
"require"
|
||||
"verify-ca"
|
||||
"verify-full"
|
||||
"prefer"
|
||||
"allow"
|
||||
];
|
||||
default = "disable";
|
||||
description = "PostgreSQL SSL mode.";
|
||||
};
|
||||
};
|
||||
|
||||
logging = {
|
||||
level = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "info";
|
||||
description = "Log level.";
|
||||
};
|
||||
|
||||
format = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"json"
|
||||
"console"
|
||||
];
|
||||
default = "json";
|
||||
description = "Log output format.";
|
||||
};
|
||||
};
|
||||
|
||||
metrics = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable Prometheus metrics endpoint.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 9090;
|
||||
description = "Prometheus metrics HTTP port.";
|
||||
};
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "metadata-agregator";
|
||||
description = "User account under which the service runs.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "metadata-agregator";
|
||||
description = "Group under which the service runs.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the gRPC and metrics ports in the firewall.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "metadata-agregator") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf (cfg.group == "metadata-agregator") { };
|
||||
|
||||
# Schema init — idempotent oneshot that runs before the main service
|
||||
systemd.services.metadata-agregator-schema-init = {
|
||||
description = "Initialize metadata-agregator database schema";
|
||||
after = [
|
||||
"network.target"
|
||||
"postgresql.service"
|
||||
];
|
||||
before = [ "metadata-agregator.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
script =
|
||||
let
|
||||
passwordExport =
|
||||
if cfg.database.passwordFile != null then
|
||||
''export PGPASSWORD="$(cat ${lib.escapeShellArg cfg.database.passwordFile})"''
|
||||
else
|
||||
"";
|
||||
psql = lib.concatStringsSep " " [
|
||||
"${pkgs.postgresql}/bin/psql"
|
||||
"-h ${lib.escapeShellArg cfg.database.host}"
|
||||
"-p ${toString cfg.database.port}"
|
||||
"-U ${lib.escapeShellArg cfg.database.user}"
|
||||
"-d ${lib.escapeShellArg cfg.database.name}"
|
||||
];
|
||||
in
|
||||
''
|
||||
${passwordExport}
|
||||
|
||||
# Wait for the database to accept connections
|
||||
for i in $(seq 1 30); do
|
||||
if ${psql} -c "SELECT 1" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check if schema already exists (idempotent marker)
|
||||
if ${psql} -tAc "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='artists'" | grep -q 1; then
|
||||
echo "Schema already initialized, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Applying metadata-agregator schema..."
|
||||
${psql} -f ${schemaFile}
|
||||
echo "Schema initialized successfully"
|
||||
'';
|
||||
};
|
||||
|
||||
# Main service
|
||||
systemd.services.metadata-agregator = {
|
||||
description = "metadata-agregator - Music metadata gRPC service";
|
||||
after = [
|
||||
"network.target"
|
||||
"metadata-agregator-schema-init.service"
|
||||
];
|
||||
requires = [ "metadata-agregator-schema-init.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = ''
|
||||
# Generate runtime config with password injected
|
||||
cp --no-preserve=mode ${configFile} /run/metadata-agregator/config.yaml
|
||||
|
||||
${
|
||||
if cfg.database.passwordFile != null then
|
||||
''
|
||||
${pkgs.replace-secret}/bin/replace-secret \
|
||||
'${dbPasswordPlaceholder}' \
|
||||
'${cfg.database.passwordFile}' \
|
||||
/run/metadata-agregator/config.yaml
|
||||
''
|
||||
else
|
||||
''
|
||||
${pkgs.gnused}/bin/sed -i 's/${dbPasswordPlaceholder}//' /run/metadata-agregator/config.yaml
|
||||
''
|
||||
}
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = "${lib.getExe cfg.package} -config /run/metadata-agregator/config.yaml";
|
||||
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
|
||||
RuntimeDirectory = "metadata-agregator";
|
||||
RuntimeDirectoryMode = "0750";
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Hardening
|
||||
ProtectSystem = "strict";
|
||||
PrivateTmp = true;
|
||||
NoNewPrivileges = true;
|
||||
ProtectHome = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
|
||||
[ cfg.server.port ] ++ lib.optional cfg.metrics.enable cfg.metrics.port
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
-- Core Entities
|
||||
CREATE EXTENSION IF NOT EXISTS pg_prewarm;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE TABLE artists (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -56,6 +57,7 @@ CREATE TABLE albums (
|
||||
label_id UUID REFERENCES labels(id),
|
||||
title TEXT NOT NULL,
|
||||
album_type TEXT,
|
||||
secondary_types TEXT[] DEFAULT '{}',
|
||||
release_date DATE,
|
||||
upc TEXT,
|
||||
total_tracks INT,
|
||||
@@ -73,8 +75,6 @@ CREATE TABLE genres (
|
||||
parent_id UUID REFERENCES genres(id)
|
||||
);
|
||||
|
||||
-- Relationships
|
||||
|
||||
CREATE TABLE track_artists (
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||
@@ -125,8 +125,6 @@ CREATE TABLE similar_artists (
|
||||
PRIMARY KEY (artist_id, similar_artist_id)
|
||||
);
|
||||
|
||||
-- Content
|
||||
|
||||
CREATE TABLE lyrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
@@ -156,8 +154,6 @@ CREATE TABLE playlist_tracks (
|
||||
PRIMARY KEY (playlist_id, track_id)
|
||||
);
|
||||
|
||||
-- External IDs
|
||||
|
||||
CREATE TABLE artist_external_ids (
|
||||
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL,
|
||||
@@ -185,9 +181,8 @@ CREATE TABLE track_external_ids (
|
||||
PRIMARY KEY (track_id, source, source_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
|
||||
CREATE INDEX idx_artists_name ON artists(name);
|
||||
CREATE INDEX idx_artists_name_trgm ON artists USING gin (name gin_trgm_ops);
|
||||
CREATE INDEX idx_artists_source ON artists(source, source_id);
|
||||
CREATE INDEX idx_tracks_isrc ON tracks(isrc) WHERE isrc IS NOT NULL;
|
||||
CREATE INDEX idx_tracks_source ON tracks(source, source_id);
|
||||
+331
-167
@@ -74,7 +74,7 @@ type GetArtistRequest struct {
|
||||
// *GetArtistRequest_Id
|
||||
// *GetArtistRequest_External
|
||||
Identifier isGetArtistRequest_Identifier `protobuf_oneof:"identifier"`
|
||||
Provider Provider `protobuf:"varint,3,opt,name=provider,proto3,enum=metadata.v1.Provider" json:"provider,omitempty"` // UNSPECIFIED = query all providers
|
||||
Provider Provider `protobuf:"varint,3,opt,name=provider,proto3,enum=metadata.v1.Provider" json:"provider,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -146,11 +146,11 @@ type isGetArtistRequest_Identifier interface {
|
||||
}
|
||||
|
||||
type GetArtistRequest_Id struct {
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3,oneof"` // Internal UUID
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3,oneof"`
|
||||
}
|
||||
|
||||
type GetArtistRequest_External struct {
|
||||
External *ExternalID `protobuf:"bytes,2,opt,name=external,proto3,oneof"` // External source ID (e.g., musicbrainz MBID)
|
||||
External *ExternalID `protobuf:"bytes,2,opt,name=external,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*GetArtistRequest_Id) isGetArtistRequest_Identifier() {}
|
||||
@@ -321,6 +321,7 @@ type GetArtistAlbumsRequest struct {
|
||||
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"`
|
||||
Offset int32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"`
|
||||
Provider Provider `protobuf:"varint,4,opt,name=provider,proto3,enum=metadata.v1.Provider" json:"provider,omitempty"`
|
||||
AlbumTypes []string `protobuf:"bytes,5,rep,name=album_types,json=albumTypes,proto3" json:"album_types,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -383,6 +384,13 @@ func (x *GetArtistAlbumsRequest) GetProvider() Provider {
|
||||
return Provider_PROVIDER_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (x *GetArtistAlbumsRequest) GetAlbumTypes() []string {
|
||||
if x != nil {
|
||||
return x.AlbumTypes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetTrackRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Identifier:
|
||||
@@ -548,6 +556,7 @@ type SearchAlbumsRequest struct {
|
||||
Limit int32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"`
|
||||
Offset int32 `protobuf:"varint,4,opt,name=offset,proto3" json:"offset,omitempty"`
|
||||
Provider Provider `protobuf:"varint,5,opt,name=provider,proto3,enum=metadata.v1.Provider" json:"provider,omitempty"`
|
||||
AlbumTypes []string `protobuf:"bytes,6,rep,name=album_types,json=albumTypes,proto3" json:"album_types,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -617,58 +626,13 @@ func (x *SearchAlbumsRequest) GetProvider() Provider {
|
||||
return Provider_PROVIDER_UNSPECIFIED
|
||||
}
|
||||
|
||||
type SearchAlbumsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Albums []*Album `protobuf:"bytes,1,rep,name=albums,proto3" json:"albums,omitempty"`
|
||||
Total int32 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) Reset() {
|
||||
*x = SearchAlbumsResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SearchAlbumsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SearchAlbumsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[7]
|
||||
func (x *SearchAlbumsRequest) GetAlbumTypes() []string {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SearchAlbumsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SearchAlbumsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) GetAlbums() []*Album {
|
||||
if x != nil {
|
||||
return x.Albums
|
||||
return x.AlbumTypes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) GetTotal() int32 {
|
||||
if x != nil {
|
||||
return x.Total
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type SyncArtistRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Target:
|
||||
@@ -683,7 +647,7 @@ type SyncArtistRequest struct {
|
||||
|
||||
func (x *SyncArtistRequest) Reset() {
|
||||
*x = SyncArtistRequest{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[8]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -695,7 +659,7 @@ func (x *SyncArtistRequest) String() string {
|
||||
func (*SyncArtistRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncArtistRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[8]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -708,7 +672,7 @@ func (x *SyncArtistRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use SyncArtistRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncArtistRequest) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{8}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *SyncArtistRequest) GetTarget() isSyncArtistRequest_Target {
|
||||
@@ -759,6 +723,50 @@ func (*SyncArtistRequest_Name) isSyncArtistRequest_Target() {}
|
||||
|
||||
func (*SyncArtistRequest_External) isSyncArtistRequest_Target() {}
|
||||
|
||||
type GetArtistResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Artist *Artist `protobuf:"bytes,1,opt,name=artist,proto3" json:"artist,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetArtistResponse) Reset() {
|
||||
*x = GetArtistResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetArtistResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetArtistResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetArtistResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetArtistResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetArtistResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *GetArtistResponse) GetArtist() *Artist {
|
||||
if x != nil {
|
||||
return x.Artist
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SearchArtistsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Artists []*Artist `protobuf:"bytes,1,rep,name=artists,proto3" json:"artists,omitempty"`
|
||||
@@ -811,6 +819,50 @@ func (x *SearchArtistsResponse) GetTotal() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetAlbumResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Album *Album `protobuf:"bytes,1,opt,name=album,proto3" json:"album,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetAlbumResponse) Reset() {
|
||||
*x = GetAlbumResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetAlbumResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetAlbumResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetAlbumResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetAlbumResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetAlbumResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *GetAlbumResponse) GetAlbum() *Album {
|
||||
if x != nil {
|
||||
return x.Album
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetArtistAlbumsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Albums []*Album `protobuf:"bytes,1,rep,name=albums,proto3" json:"albums,omitempty"`
|
||||
@@ -821,7 +873,7 @@ type GetArtistAlbumsResponse struct {
|
||||
|
||||
func (x *GetArtistAlbumsResponse) Reset() {
|
||||
*x = GetArtistAlbumsResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[10]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -833,7 +885,7 @@ func (x *GetArtistAlbumsResponse) String() string {
|
||||
func (*GetArtistAlbumsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetArtistAlbumsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[10]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -846,7 +898,7 @@ func (x *GetArtistAlbumsResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use GetArtistAlbumsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetArtistAlbumsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{10}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *GetArtistAlbumsResponse) GetAlbums() []*Album {
|
||||
@@ -863,6 +915,50 @@ func (x *GetArtistAlbumsResponse) GetTotal() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetTrackResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Track *Track `protobuf:"bytes,1,opt,name=track,proto3" json:"track,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetTrackResponse) Reset() {
|
||||
*x = GetTrackResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetTrackResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetTrackResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetTrackResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetTrackResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetTrackResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *GetTrackResponse) GetTrack() *Track {
|
||||
if x != nil {
|
||||
return x.Track
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetAlbumTracksResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Tracks []*Track `protobuf:"bytes,1,rep,name=tracks,proto3" json:"tracks,omitempty"`
|
||||
@@ -872,7 +968,7 @@ type GetAlbumTracksResponse struct {
|
||||
|
||||
func (x *GetAlbumTracksResponse) Reset() {
|
||||
*x = GetAlbumTracksResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[11]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -884,7 +980,7 @@ func (x *GetAlbumTracksResponse) String() string {
|
||||
func (*GetAlbumTracksResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetAlbumTracksResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[11]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -897,7 +993,7 @@ func (x *GetAlbumTracksResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use GetAlbumTracksResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetAlbumTracksResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{11}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *GetAlbumTracksResponse) GetTracks() []*Track {
|
||||
@@ -907,6 +1003,58 @@ func (x *GetAlbumTracksResponse) GetTracks() []*Track {
|
||||
return nil
|
||||
}
|
||||
|
||||
type SearchAlbumsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Albums []*Album `protobuf:"bytes,1,rep,name=albums,proto3" json:"albums,omitempty"`
|
||||
Total int32 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) Reset() {
|
||||
*x = SearchAlbumsResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SearchAlbumsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SearchAlbumsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SearchAlbumsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SearchAlbumsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) GetAlbums() []*Album {
|
||||
if x != nil {
|
||||
return x.Albums
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SearchAlbumsResponse) GetTotal() int32 {
|
||||
if x != nil {
|
||||
return x.Total
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type SyncArtistResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Artist *Artist `protobuf:"bytes,1,opt,name=artist,proto3" json:"artist,omitempty"`
|
||||
@@ -918,7 +1066,7 @@ type SyncArtistResponse struct {
|
||||
|
||||
func (x *SyncArtistResponse) Reset() {
|
||||
*x = SyncArtistResponse{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[12]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -930,7 +1078,7 @@ func (x *SyncArtistResponse) String() string {
|
||||
func (*SyncArtistResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncArtistResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[12]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -943,7 +1091,7 @@ func (x *SyncArtistResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use SyncArtistResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncArtistResponse) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{12}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *SyncArtistResponse) GetArtist() *Artist {
|
||||
@@ -972,7 +1120,7 @@ type Artist struct {
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
SortName string `protobuf:"bytes,3,opt,name=sort_name,json=sortName,proto3" json:"sort_name,omitempty"`
|
||||
ArtistType string `protobuf:"bytes,4,opt,name=artist_type,json=artistType,proto3" json:"artist_type,omitempty"` // person, group, orchestra, etc.
|
||||
ArtistType string `protobuf:"bytes,4,opt,name=artist_type,json=artistType,proto3" json:"artist_type,omitempty"`
|
||||
Country string `protobuf:"bytes,5,opt,name=country,proto3" json:"country,omitempty"`
|
||||
FormedDate string `protobuf:"bytes,6,opt,name=formed_date,json=formedDate,proto3" json:"formed_date,omitempty"`
|
||||
DisbandedDate string `protobuf:"bytes,7,opt,name=disbanded_date,json=disbandedDate,proto3" json:"disbanded_date,omitempty"`
|
||||
@@ -986,7 +1134,7 @@ type Artist struct {
|
||||
|
||||
func (x *Artist) Reset() {
|
||||
*x = Artist{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[13]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[16]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -998,7 +1146,7 @@ func (x *Artist) String() string {
|
||||
func (*Artist) ProtoMessage() {}
|
||||
|
||||
func (x *Artist) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[13]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[16]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1011,7 +1159,7 @@ func (x *Artist) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Artist.ProtoReflect.Descriptor instead.
|
||||
func (*Artist) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{13}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{16}
|
||||
}
|
||||
|
||||
func (x *Artist) GetId() string {
|
||||
@@ -1095,7 +1243,7 @@ type Album struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
||||
AlbumType string `protobuf:"bytes,3,opt,name=album_type,json=albumType,proto3" json:"album_type,omitempty"` // album, ep, single, compilation
|
||||
AlbumType string `protobuf:"bytes,3,opt,name=album_type,json=albumType,proto3" json:"album_type,omitempty"`
|
||||
ReleaseDate string `protobuf:"bytes,4,opt,name=release_date,json=releaseDate,proto3" json:"release_date,omitempty"`
|
||||
Upc string `protobuf:"bytes,5,opt,name=upc,proto3" json:"upc,omitempty"`
|
||||
TotalTracks int32 `protobuf:"varint,6,opt,name=total_tracks,json=totalTracks,proto3" json:"total_tracks,omitempty"`
|
||||
@@ -1111,7 +1259,7 @@ type Album struct {
|
||||
|
||||
func (x *Album) Reset() {
|
||||
*x = Album{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[14]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1123,7 +1271,7 @@ func (x *Album) String() string {
|
||||
func (*Album) ProtoMessage() {}
|
||||
|
||||
func (x *Album) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[14]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1136,7 +1284,7 @@ func (x *Album) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Album.ProtoReflect.Descriptor instead.
|
||||
func (*Album) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{14}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{17}
|
||||
}
|
||||
|
||||
func (x *Album) GetId() string {
|
||||
@@ -1241,7 +1389,7 @@ type Track struct {
|
||||
|
||||
func (x *Track) Reset() {
|
||||
*x = Track{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[15]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1253,7 +1401,7 @@ func (x *Track) String() string {
|
||||
func (*Track) ProtoMessage() {}
|
||||
|
||||
func (x *Track) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[15]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[18]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1266,7 +1414,7 @@ func (x *Track) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Track.ProtoReflect.Descriptor instead.
|
||||
func (*Track) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{15}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{18}
|
||||
}
|
||||
|
||||
func (x *Track) GetId() string {
|
||||
@@ -1352,7 +1500,7 @@ type Work struct {
|
||||
|
||||
func (x *Work) Reset() {
|
||||
*x = Work{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[16]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[19]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1364,7 +1512,7 @@ func (x *Work) String() string {
|
||||
func (*Work) ProtoMessage() {}
|
||||
|
||||
func (x *Work) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[16]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[19]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1377,7 +1525,7 @@ func (x *Work) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Work.ProtoReflect.Descriptor instead.
|
||||
func (*Work) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{16}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{19}
|
||||
}
|
||||
|
||||
func (x *Work) GetId() string {
|
||||
@@ -1426,7 +1574,7 @@ type Label struct {
|
||||
|
||||
func (x *Label) Reset() {
|
||||
*x = Label{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[17]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[20]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1438,7 +1586,7 @@ func (x *Label) String() string {
|
||||
func (*Label) ProtoMessage() {}
|
||||
|
||||
func (x *Label) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[17]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[20]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1451,7 +1599,7 @@ func (x *Label) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Label.ProtoReflect.Descriptor instead.
|
||||
func (*Label) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{17}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{20}
|
||||
}
|
||||
|
||||
func (x *Label) GetId() string {
|
||||
@@ -1485,7 +1633,7 @@ type Genre struct {
|
||||
|
||||
func (x *Genre) Reset() {
|
||||
*x = Genre{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[18]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1497,7 +1645,7 @@ func (x *Genre) String() string {
|
||||
func (*Genre) ProtoMessage() {}
|
||||
|
||||
func (x *Genre) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[18]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[21]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1510,7 +1658,7 @@ func (x *Genre) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Genre.ProtoReflect.Descriptor instead.
|
||||
func (*Genre) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{18}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{21}
|
||||
}
|
||||
|
||||
func (x *Genre) GetId() string {
|
||||
@@ -1530,16 +1678,16 @@ func (x *Genre) GetName() string {
|
||||
type ArtistCredit struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Artist *Artist `protobuf:"bytes,1,opt,name=artist,proto3" json:"artist,omitempty"`
|
||||
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` // primary, featured, remixer, producer
|
||||
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
|
||||
Position int32 `protobuf:"varint,3,opt,name=position,proto3" json:"position,omitempty"`
|
||||
JoinPhrase string `protobuf:"bytes,4,opt,name=join_phrase,json=joinPhrase,proto3" json:"join_phrase,omitempty"` // " & ", " feat. ", etc.
|
||||
JoinPhrase string `protobuf:"bytes,4,opt,name=join_phrase,json=joinPhrase,proto3" json:"join_phrase,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ArtistCredit) Reset() {
|
||||
*x = ArtistCredit{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[19]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1551,7 +1699,7 @@ func (x *ArtistCredit) String() string {
|
||||
func (*ArtistCredit) ProtoMessage() {}
|
||||
|
||||
func (x *ArtistCredit) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[19]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[22]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1564,7 +1712,7 @@ func (x *ArtistCredit) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ArtistCredit.ProtoReflect.Descriptor instead.
|
||||
func (*ArtistCredit) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{19}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{22}
|
||||
}
|
||||
|
||||
func (x *ArtistCredit) GetArtist() *Artist {
|
||||
@@ -1597,7 +1745,7 @@ func (x *ArtistCredit) GetJoinPhrase() string {
|
||||
|
||||
type ExternalID struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` // musicbrainz, spotify, discogs, etc.
|
||||
Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"`
|
||||
SourceId string `protobuf:"bytes,2,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"`
|
||||
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -1606,7 +1754,7 @@ type ExternalID struct {
|
||||
|
||||
func (x *ExternalID) Reset() {
|
||||
*x = ExternalID{}
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[20]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1618,7 +1766,7 @@ func (x *ExternalID) String() string {
|
||||
func (*ExternalID) ProtoMessage() {}
|
||||
|
||||
func (x *ExternalID) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[20]
|
||||
mi := &file_metadata_v1_metadata_proto_msgTypes[23]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1631,7 +1779,7 @@ func (x *ExternalID) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ExternalID.ProtoReflect.Descriptor instead.
|
||||
func (*ExternalID) Descriptor() ([]byte, []int) {
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{20}
|
||||
return file_metadata_v1_metadata_proto_rawDescGZIP(), []int{23}
|
||||
}
|
||||
|
||||
func (x *ExternalID) GetSource() string {
|
||||
@@ -1676,12 +1824,14 @@ const file_metadata_v1_metadata_proto_rawDesc = "" +
|
||||
"\bexternal\x18\x02 \x01(\v2\x17.metadata.v1.ExternalIDH\x00R\bexternal\x121\n" +
|
||||
"\bprovider\x18\x03 \x01(\x0e2\x15.metadata.v1.ProviderR\bproviderB\f\n" +
|
||||
"\n" +
|
||||
"identifier\"\x96\x01\n" +
|
||||
"identifier\"\xb7\x01\n" +
|
||||
"\x16GetArtistAlbumsRequest\x12\x1b\n" +
|
||||
"\tartist_id\x18\x01 \x01(\tR\bartistId\x12\x14\n" +
|
||||
"\x05limit\x18\x02 \x01(\x05R\x05limit\x12\x16\n" +
|
||||
"\x06offset\x18\x03 \x01(\x05R\x06offset\x121\n" +
|
||||
"\bprovider\x18\x04 \x01(\x0e2\x15.metadata.v1.ProviderR\bprovider\"\xb1\x01\n" +
|
||||
"\bprovider\x18\x04 \x01(\x0e2\x15.metadata.v1.ProviderR\bprovider\x12\x1f\n" +
|
||||
"\valbum_types\x18\x05 \x03(\tR\n" +
|
||||
"albumTypes\"\xb1\x01\n" +
|
||||
"\x0fGetTrackRequest\x12\x10\n" +
|
||||
"\x02id\x18\x01 \x01(\tH\x00R\x02id\x125\n" +
|
||||
"\bexternal\x18\x02 \x01(\v2\x17.metadata.v1.ExternalIDH\x00R\bexternal\x12\x14\n" +
|
||||
@@ -1691,29 +1841,37 @@ const file_metadata_v1_metadata_proto_rawDesc = "" +
|
||||
"identifier\"e\n" +
|
||||
"\x15GetAlbumTracksRequest\x12\x19\n" +
|
||||
"\balbum_id\x18\x01 \x01(\tR\aalbumId\x121\n" +
|
||||
"\bprovider\x18\x02 \x01(\x0e2\x15.metadata.v1.ProviderR\bprovider\"\xa4\x01\n" +
|
||||
"\bprovider\x18\x02 \x01(\x0e2\x15.metadata.v1.ProviderR\bprovider\"\xc5\x01\n" +
|
||||
"\x13SearchAlbumsRequest\x12\x14\n" +
|
||||
"\x05query\x18\x01 \x01(\tR\x05query\x12\x16\n" +
|
||||
"\x06artist\x18\x02 \x01(\tR\x06artist\x12\x14\n" +
|
||||
"\x05limit\x18\x03 \x01(\x05R\x05limit\x12\x16\n" +
|
||||
"\x06offset\x18\x04 \x01(\x05R\x06offset\x121\n" +
|
||||
"\bprovider\x18\x05 \x01(\x0e2\x15.metadata.v1.ProviderR\bprovider\"X\n" +
|
||||
"\x14SearchAlbumsResponse\x12*\n" +
|
||||
"\x06albums\x18\x01 \x03(\v2\x12.metadata.v1.AlbumR\x06albums\x12\x14\n" +
|
||||
"\x05total\x18\x02 \x01(\x05R\x05total\"\x9d\x01\n" +
|
||||
"\bprovider\x18\x05 \x01(\x0e2\x15.metadata.v1.ProviderR\bprovider\x12\x1f\n" +
|
||||
"\valbum_types\x18\x06 \x03(\tR\n" +
|
||||
"albumTypes\"\x9d\x01\n" +
|
||||
"\x11SyncArtistRequest\x12\x14\n" +
|
||||
"\x04name\x18\x01 \x01(\tH\x00R\x04name\x125\n" +
|
||||
"\bexternal\x18\x02 \x01(\v2\x17.metadata.v1.ExternalIDH\x00R\bexternal\x121\n" +
|
||||
"\bprovider\x18\x03 \x01(\x0e2\x15.metadata.v1.ProviderR\bproviderB\b\n" +
|
||||
"\x06target\"\\\n" +
|
||||
"\x06target\"@\n" +
|
||||
"\x11GetArtistResponse\x12+\n" +
|
||||
"\x06artist\x18\x01 \x01(\v2\x13.metadata.v1.ArtistR\x06artist\"\\\n" +
|
||||
"\x15SearchArtistsResponse\x12-\n" +
|
||||
"\aartists\x18\x01 \x03(\v2\x13.metadata.v1.ArtistR\aartists\x12\x14\n" +
|
||||
"\x05total\x18\x02 \x01(\x05R\x05total\"[\n" +
|
||||
"\x05total\x18\x02 \x01(\x05R\x05total\"<\n" +
|
||||
"\x10GetAlbumResponse\x12(\n" +
|
||||
"\x05album\x18\x01 \x01(\v2\x12.metadata.v1.AlbumR\x05album\"[\n" +
|
||||
"\x17GetArtistAlbumsResponse\x12*\n" +
|
||||
"\x06albums\x18\x01 \x03(\v2\x12.metadata.v1.AlbumR\x06albums\x12\x14\n" +
|
||||
"\x05total\x18\x02 \x01(\x05R\x05total\"D\n" +
|
||||
"\x05total\x18\x02 \x01(\x05R\x05total\"<\n" +
|
||||
"\x10GetTrackResponse\x12(\n" +
|
||||
"\x05track\x18\x01 \x01(\v2\x12.metadata.v1.TrackR\x05track\"D\n" +
|
||||
"\x16GetAlbumTracksResponse\x12*\n" +
|
||||
"\x06tracks\x18\x01 \x03(\v2\x12.metadata.v1.TrackR\x06tracks\"\x8b\x01\n" +
|
||||
"\x06tracks\x18\x01 \x03(\v2\x12.metadata.v1.TrackR\x06tracks\"X\n" +
|
||||
"\x14SearchAlbumsResponse\x12*\n" +
|
||||
"\x06albums\x18\x01 \x03(\v2\x12.metadata.v1.AlbumR\x06albums\x12\x14\n" +
|
||||
"\x05total\x18\x02 \x01(\x05R\x05total\"\x8b\x01\n" +
|
||||
"\x12SyncArtistResponse\x12+\n" +
|
||||
"\x06artist\x18\x01 \x01(\v2\x13.metadata.v1.ArtistR\x06artist\x12#\n" +
|
||||
"\ralbums_synced\x18\x02 \x01(\x05R\falbumsSynced\x12#\n" +
|
||||
@@ -1789,13 +1947,13 @@ const file_metadata_v1_metadata_proto_rawDesc = "" +
|
||||
"\x03url\x18\x03 \x01(\tR\x03url*>\n" +
|
||||
"\bProvider\x12\x18\n" +
|
||||
"\x14PROVIDER_UNSPECIFIED\x10\x00\x12\x18\n" +
|
||||
"\x14PROVIDER_MUSICBRAINZ\x10\x012\x83\x05\n" +
|
||||
"\x0fMetadataService\x12?\n" +
|
||||
"\tGetArtist\x12\x1d.metadata.v1.GetArtistRequest\x1a\x13.metadata.v1.Artist\x12V\n" +
|
||||
"\rSearchArtists\x12!.metadata.v1.SearchArtistsRequest\x1a\".metadata.v1.SearchArtistsResponse\x12<\n" +
|
||||
"\bGetAlbum\x12\x1c.metadata.v1.GetAlbumRequest\x1a\x12.metadata.v1.Album\x12\\\n" +
|
||||
"\x0fGetArtistAlbums\x12#.metadata.v1.GetArtistAlbumsRequest\x1a$.metadata.v1.GetArtistAlbumsResponse\x12<\n" +
|
||||
"\bGetTrack\x12\x1c.metadata.v1.GetTrackRequest\x1a\x12.metadata.v1.Track\x12Y\n" +
|
||||
"\x14PROVIDER_MUSICBRAINZ\x10\x012\xa4\x05\n" +
|
||||
"\x0fMetadataService\x12J\n" +
|
||||
"\tGetArtist\x12\x1d.metadata.v1.GetArtistRequest\x1a\x1e.metadata.v1.GetArtistResponse\x12V\n" +
|
||||
"\rSearchArtists\x12!.metadata.v1.SearchArtistsRequest\x1a\".metadata.v1.SearchArtistsResponse\x12G\n" +
|
||||
"\bGetAlbum\x12\x1c.metadata.v1.GetAlbumRequest\x1a\x1d.metadata.v1.GetAlbumResponse\x12\\\n" +
|
||||
"\x0fGetArtistAlbums\x12#.metadata.v1.GetArtistAlbumsRequest\x1a$.metadata.v1.GetArtistAlbumsResponse\x12G\n" +
|
||||
"\bGetTrack\x12\x1c.metadata.v1.GetTrackRequest\x1a\x1d.metadata.v1.GetTrackResponse\x12Y\n" +
|
||||
"\x0eGetAlbumTracks\x12\".metadata.v1.GetAlbumTracksRequest\x1a#.metadata.v1.GetAlbumTracksResponse\x12S\n" +
|
||||
"\fSearchAlbums\x12 .metadata.v1.SearchAlbumsRequest\x1a!.metadata.v1.SearchAlbumsResponse\x12M\n" +
|
||||
"\n" +
|
||||
@@ -1815,7 +1973,7 @@ func file_metadata_v1_metadata_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_metadata_v1_metadata_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_metadata_v1_metadata_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
|
||||
var file_metadata_v1_metadata_proto_msgTypes = make([]protoimpl.MessageInfo, 24)
|
||||
var file_metadata_v1_metadata_proto_goTypes = []any{
|
||||
(Provider)(0), // 0: metadata.v1.Provider
|
||||
(*GetArtistRequest)(nil), // 1: metadata.v1.GetArtistRequest
|
||||
@@ -1825,71 +1983,77 @@ var file_metadata_v1_metadata_proto_goTypes = []any{
|
||||
(*GetTrackRequest)(nil), // 5: metadata.v1.GetTrackRequest
|
||||
(*GetAlbumTracksRequest)(nil), // 6: metadata.v1.GetAlbumTracksRequest
|
||||
(*SearchAlbumsRequest)(nil), // 7: metadata.v1.SearchAlbumsRequest
|
||||
(*SearchAlbumsResponse)(nil), // 8: metadata.v1.SearchAlbumsResponse
|
||||
(*SyncArtistRequest)(nil), // 9: metadata.v1.SyncArtistRequest
|
||||
(*SyncArtistRequest)(nil), // 8: metadata.v1.SyncArtistRequest
|
||||
(*GetArtistResponse)(nil), // 9: metadata.v1.GetArtistResponse
|
||||
(*SearchArtistsResponse)(nil), // 10: metadata.v1.SearchArtistsResponse
|
||||
(*GetArtistAlbumsResponse)(nil), // 11: metadata.v1.GetArtistAlbumsResponse
|
||||
(*GetAlbumTracksResponse)(nil), // 12: metadata.v1.GetAlbumTracksResponse
|
||||
(*SyncArtistResponse)(nil), // 13: metadata.v1.SyncArtistResponse
|
||||
(*Artist)(nil), // 14: metadata.v1.Artist
|
||||
(*Album)(nil), // 15: metadata.v1.Album
|
||||
(*Track)(nil), // 16: metadata.v1.Track
|
||||
(*Work)(nil), // 17: metadata.v1.Work
|
||||
(*Label)(nil), // 18: metadata.v1.Label
|
||||
(*Genre)(nil), // 19: metadata.v1.Genre
|
||||
(*ArtistCredit)(nil), // 20: metadata.v1.ArtistCredit
|
||||
(*ExternalID)(nil), // 21: metadata.v1.ExternalID
|
||||
(*GetAlbumResponse)(nil), // 11: metadata.v1.GetAlbumResponse
|
||||
(*GetArtistAlbumsResponse)(nil), // 12: metadata.v1.GetArtistAlbumsResponse
|
||||
(*GetTrackResponse)(nil), // 13: metadata.v1.GetTrackResponse
|
||||
(*GetAlbumTracksResponse)(nil), // 14: metadata.v1.GetAlbumTracksResponse
|
||||
(*SearchAlbumsResponse)(nil), // 15: metadata.v1.SearchAlbumsResponse
|
||||
(*SyncArtistResponse)(nil), // 16: metadata.v1.SyncArtistResponse
|
||||
(*Artist)(nil), // 17: metadata.v1.Artist
|
||||
(*Album)(nil), // 18: metadata.v1.Album
|
||||
(*Track)(nil), // 19: metadata.v1.Track
|
||||
(*Work)(nil), // 20: metadata.v1.Work
|
||||
(*Label)(nil), // 21: metadata.v1.Label
|
||||
(*Genre)(nil), // 22: metadata.v1.Genre
|
||||
(*ArtistCredit)(nil), // 23: metadata.v1.ArtistCredit
|
||||
(*ExternalID)(nil), // 24: metadata.v1.ExternalID
|
||||
}
|
||||
var file_metadata_v1_metadata_proto_depIdxs = []int32{
|
||||
21, // 0: metadata.v1.GetArtistRequest.external:type_name -> metadata.v1.ExternalID
|
||||
24, // 0: metadata.v1.GetArtistRequest.external:type_name -> metadata.v1.ExternalID
|
||||
0, // 1: metadata.v1.GetArtistRequest.provider:type_name -> metadata.v1.Provider
|
||||
0, // 2: metadata.v1.SearchArtistsRequest.provider:type_name -> metadata.v1.Provider
|
||||
21, // 3: metadata.v1.GetAlbumRequest.external:type_name -> metadata.v1.ExternalID
|
||||
24, // 3: metadata.v1.GetAlbumRequest.external:type_name -> metadata.v1.ExternalID
|
||||
0, // 4: metadata.v1.GetAlbumRequest.provider:type_name -> metadata.v1.Provider
|
||||
0, // 5: metadata.v1.GetArtistAlbumsRequest.provider:type_name -> metadata.v1.Provider
|
||||
21, // 6: metadata.v1.GetTrackRequest.external:type_name -> metadata.v1.ExternalID
|
||||
24, // 6: metadata.v1.GetTrackRequest.external:type_name -> metadata.v1.ExternalID
|
||||
0, // 7: metadata.v1.GetTrackRequest.provider:type_name -> metadata.v1.Provider
|
||||
0, // 8: metadata.v1.GetAlbumTracksRequest.provider:type_name -> metadata.v1.Provider
|
||||
0, // 9: metadata.v1.SearchAlbumsRequest.provider:type_name -> metadata.v1.Provider
|
||||
15, // 10: metadata.v1.SearchAlbumsResponse.albums:type_name -> metadata.v1.Album
|
||||
21, // 11: metadata.v1.SyncArtistRequest.external:type_name -> metadata.v1.ExternalID
|
||||
0, // 12: metadata.v1.SyncArtistRequest.provider:type_name -> metadata.v1.Provider
|
||||
14, // 13: metadata.v1.SearchArtistsResponse.artists:type_name -> metadata.v1.Artist
|
||||
15, // 14: metadata.v1.GetArtistAlbumsResponse.albums:type_name -> metadata.v1.Album
|
||||
16, // 15: metadata.v1.GetAlbumTracksResponse.tracks:type_name -> metadata.v1.Track
|
||||
14, // 16: metadata.v1.SyncArtistResponse.artist:type_name -> metadata.v1.Artist
|
||||
19, // 17: metadata.v1.Artist.genres:type_name -> metadata.v1.Genre
|
||||
21, // 18: metadata.v1.Artist.external_ids:type_name -> metadata.v1.ExternalID
|
||||
20, // 19: metadata.v1.Album.artists:type_name -> metadata.v1.ArtistCredit
|
||||
18, // 20: metadata.v1.Album.label:type_name -> metadata.v1.Label
|
||||
19, // 21: metadata.v1.Album.genres:type_name -> metadata.v1.Genre
|
||||
21, // 22: metadata.v1.Album.external_ids:type_name -> metadata.v1.ExternalID
|
||||
20, // 23: metadata.v1.Track.artists:type_name -> metadata.v1.ArtistCredit
|
||||
17, // 24: metadata.v1.Track.work:type_name -> metadata.v1.Work
|
||||
21, // 25: metadata.v1.Track.external_ids:type_name -> metadata.v1.ExternalID
|
||||
20, // 26: metadata.v1.Work.composers:type_name -> metadata.v1.ArtistCredit
|
||||
14, // 27: metadata.v1.ArtistCredit.artist:type_name -> metadata.v1.Artist
|
||||
1, // 28: metadata.v1.MetadataService.GetArtist:input_type -> metadata.v1.GetArtistRequest
|
||||
2, // 29: metadata.v1.MetadataService.SearchArtists:input_type -> metadata.v1.SearchArtistsRequest
|
||||
3, // 30: metadata.v1.MetadataService.GetAlbum:input_type -> metadata.v1.GetAlbumRequest
|
||||
4, // 31: metadata.v1.MetadataService.GetArtistAlbums:input_type -> metadata.v1.GetArtistAlbumsRequest
|
||||
5, // 32: metadata.v1.MetadataService.GetTrack:input_type -> metadata.v1.GetTrackRequest
|
||||
6, // 33: metadata.v1.MetadataService.GetAlbumTracks:input_type -> metadata.v1.GetAlbumTracksRequest
|
||||
7, // 34: metadata.v1.MetadataService.SearchAlbums:input_type -> metadata.v1.SearchAlbumsRequest
|
||||
9, // 35: metadata.v1.MetadataService.SyncArtist:input_type -> metadata.v1.SyncArtistRequest
|
||||
14, // 36: metadata.v1.MetadataService.GetArtist:output_type -> metadata.v1.Artist
|
||||
10, // 37: metadata.v1.MetadataService.SearchArtists:output_type -> metadata.v1.SearchArtistsResponse
|
||||
15, // 38: metadata.v1.MetadataService.GetAlbum:output_type -> metadata.v1.Album
|
||||
11, // 39: metadata.v1.MetadataService.GetArtistAlbums:output_type -> metadata.v1.GetArtistAlbumsResponse
|
||||
16, // 40: metadata.v1.MetadataService.GetTrack:output_type -> metadata.v1.Track
|
||||
12, // 41: metadata.v1.MetadataService.GetAlbumTracks:output_type -> metadata.v1.GetAlbumTracksResponse
|
||||
8, // 42: metadata.v1.MetadataService.SearchAlbums:output_type -> metadata.v1.SearchAlbumsResponse
|
||||
13, // 43: metadata.v1.MetadataService.SyncArtist:output_type -> metadata.v1.SyncArtistResponse
|
||||
36, // [36:44] is the sub-list for method output_type
|
||||
28, // [28:36] is the sub-list for method input_type
|
||||
28, // [28:28] is the sub-list for extension type_name
|
||||
28, // [28:28] is the sub-list for extension extendee
|
||||
0, // [0:28] is the sub-list for field type_name
|
||||
24, // 10: metadata.v1.SyncArtistRequest.external:type_name -> metadata.v1.ExternalID
|
||||
0, // 11: metadata.v1.SyncArtistRequest.provider:type_name -> metadata.v1.Provider
|
||||
17, // 12: metadata.v1.GetArtistResponse.artist:type_name -> metadata.v1.Artist
|
||||
17, // 13: metadata.v1.SearchArtistsResponse.artists:type_name -> metadata.v1.Artist
|
||||
18, // 14: metadata.v1.GetAlbumResponse.album:type_name -> metadata.v1.Album
|
||||
18, // 15: metadata.v1.GetArtistAlbumsResponse.albums:type_name -> metadata.v1.Album
|
||||
19, // 16: metadata.v1.GetTrackResponse.track:type_name -> metadata.v1.Track
|
||||
19, // 17: metadata.v1.GetAlbumTracksResponse.tracks:type_name -> metadata.v1.Track
|
||||
18, // 18: metadata.v1.SearchAlbumsResponse.albums:type_name -> metadata.v1.Album
|
||||
17, // 19: metadata.v1.SyncArtistResponse.artist:type_name -> metadata.v1.Artist
|
||||
22, // 20: metadata.v1.Artist.genres:type_name -> metadata.v1.Genre
|
||||
24, // 21: metadata.v1.Artist.external_ids:type_name -> metadata.v1.ExternalID
|
||||
23, // 22: metadata.v1.Album.artists:type_name -> metadata.v1.ArtistCredit
|
||||
21, // 23: metadata.v1.Album.label:type_name -> metadata.v1.Label
|
||||
22, // 24: metadata.v1.Album.genres:type_name -> metadata.v1.Genre
|
||||
24, // 25: metadata.v1.Album.external_ids:type_name -> metadata.v1.ExternalID
|
||||
23, // 26: metadata.v1.Track.artists:type_name -> metadata.v1.ArtistCredit
|
||||
20, // 27: metadata.v1.Track.work:type_name -> metadata.v1.Work
|
||||
24, // 28: metadata.v1.Track.external_ids:type_name -> metadata.v1.ExternalID
|
||||
23, // 29: metadata.v1.Work.composers:type_name -> metadata.v1.ArtistCredit
|
||||
17, // 30: metadata.v1.ArtistCredit.artist:type_name -> metadata.v1.Artist
|
||||
1, // 31: metadata.v1.MetadataService.GetArtist:input_type -> metadata.v1.GetArtistRequest
|
||||
2, // 32: metadata.v1.MetadataService.SearchArtists:input_type -> metadata.v1.SearchArtistsRequest
|
||||
3, // 33: metadata.v1.MetadataService.GetAlbum:input_type -> metadata.v1.GetAlbumRequest
|
||||
4, // 34: metadata.v1.MetadataService.GetArtistAlbums:input_type -> metadata.v1.GetArtistAlbumsRequest
|
||||
5, // 35: metadata.v1.MetadataService.GetTrack:input_type -> metadata.v1.GetTrackRequest
|
||||
6, // 36: metadata.v1.MetadataService.GetAlbumTracks:input_type -> metadata.v1.GetAlbumTracksRequest
|
||||
7, // 37: metadata.v1.MetadataService.SearchAlbums:input_type -> metadata.v1.SearchAlbumsRequest
|
||||
8, // 38: metadata.v1.MetadataService.SyncArtist:input_type -> metadata.v1.SyncArtistRequest
|
||||
9, // 39: metadata.v1.MetadataService.GetArtist:output_type -> metadata.v1.GetArtistResponse
|
||||
10, // 40: metadata.v1.MetadataService.SearchArtists:output_type -> metadata.v1.SearchArtistsResponse
|
||||
11, // 41: metadata.v1.MetadataService.GetAlbum:output_type -> metadata.v1.GetAlbumResponse
|
||||
12, // 42: metadata.v1.MetadataService.GetArtistAlbums:output_type -> metadata.v1.GetArtistAlbumsResponse
|
||||
13, // 43: metadata.v1.MetadataService.GetTrack:output_type -> metadata.v1.GetTrackResponse
|
||||
14, // 44: metadata.v1.MetadataService.GetAlbumTracks:output_type -> metadata.v1.GetAlbumTracksResponse
|
||||
15, // 45: metadata.v1.MetadataService.SearchAlbums:output_type -> metadata.v1.SearchAlbumsResponse
|
||||
16, // 46: metadata.v1.MetadataService.SyncArtist:output_type -> metadata.v1.SyncArtistResponse
|
||||
39, // [39:47] is the sub-list for method output_type
|
||||
31, // [31:39] is the sub-list for method input_type
|
||||
31, // [31:31] is the sub-list for extension type_name
|
||||
31, // [31:31] is the sub-list for extension extendee
|
||||
0, // [0:31] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_metadata_v1_metadata_proto_init() }
|
||||
@@ -1910,7 +2074,7 @@ func file_metadata_v1_metadata_proto_init() {
|
||||
(*GetTrackRequest_External)(nil),
|
||||
(*GetTrackRequest_Isrc)(nil),
|
||||
}
|
||||
file_metadata_v1_metadata_proto_msgTypes[8].OneofWrappers = []any{
|
||||
file_metadata_v1_metadata_proto_msgTypes[7].OneofWrappers = []any{
|
||||
(*SyncArtistRequest_Name)(nil),
|
||||
(*SyncArtistRequest_External)(nil),
|
||||
}
|
||||
@@ -1920,7 +2084,7 @@ func file_metadata_v1_metadata_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_metadata_v1_metadata_proto_rawDesc), len(file_metadata_v1_metadata_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 21,
|
||||
NumMessages: 24,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -32,24 +32,14 @@ const (
|
||||
// MetadataServiceClient is the client API for MetadataService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// MetadataService provides music metadata aggregation.
|
||||
type MetadataServiceClient interface {
|
||||
// GetArtist retrieves an artist by ID or external source ID.
|
||||
GetArtist(ctx context.Context, in *GetArtistRequest, opts ...grpc.CallOption) (*Artist, error)
|
||||
// SearchArtists searches for artists by name.
|
||||
GetArtist(ctx context.Context, in *GetArtistRequest, opts ...grpc.CallOption) (*GetArtistResponse, error)
|
||||
SearchArtists(ctx context.Context, in *SearchArtistsRequest, opts ...grpc.CallOption) (*SearchArtistsResponse, error)
|
||||
// GetAlbum retrieves an album by ID.
|
||||
GetAlbum(ctx context.Context, in *GetAlbumRequest, opts ...grpc.CallOption) (*Album, error)
|
||||
// GetArtistAlbums retrieves all albums by an artist.
|
||||
GetAlbum(ctx context.Context, in *GetAlbumRequest, opts ...grpc.CallOption) (*GetAlbumResponse, error)
|
||||
GetArtistAlbums(ctx context.Context, in *GetArtistAlbumsRequest, opts ...grpc.CallOption) (*GetArtistAlbumsResponse, error)
|
||||
// GetTrack retrieves a track by ID.
|
||||
GetTrack(ctx context.Context, in *GetTrackRequest, opts ...grpc.CallOption) (*Track, error)
|
||||
// GetAlbumTracks retrieves all tracks on an album.
|
||||
GetTrack(ctx context.Context, in *GetTrackRequest, opts ...grpc.CallOption) (*GetTrackResponse, error)
|
||||
GetAlbumTracks(ctx context.Context, in *GetAlbumTracksRequest, opts ...grpc.CallOption) (*GetAlbumTracksResponse, error)
|
||||
// SearchAlbums searches for albums by name, optionally filtered by artist.
|
||||
SearchAlbums(ctx context.Context, in *SearchAlbumsRequest, opts ...grpc.CallOption) (*SearchAlbumsResponse, error)
|
||||
// SyncArtist triggers ingestion of an artist from external sources.
|
||||
SyncArtist(ctx context.Context, in *SyncArtistRequest, opts ...grpc.CallOption) (*SyncArtistResponse, error)
|
||||
}
|
||||
|
||||
@@ -61,9 +51,9 @@ func NewMetadataServiceClient(cc grpc.ClientConnInterface) MetadataServiceClient
|
||||
return &metadataServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *metadataServiceClient) GetArtist(ctx context.Context, in *GetArtistRequest, opts ...grpc.CallOption) (*Artist, error) {
|
||||
func (c *metadataServiceClient) GetArtist(ctx context.Context, in *GetArtistRequest, opts ...grpc.CallOption) (*GetArtistResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Artist)
|
||||
out := new(GetArtistResponse)
|
||||
err := c.cc.Invoke(ctx, MetadataService_GetArtist_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -81,9 +71,9 @@ func (c *metadataServiceClient) SearchArtists(ctx context.Context, in *SearchArt
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *metadataServiceClient) GetAlbum(ctx context.Context, in *GetAlbumRequest, opts ...grpc.CallOption) (*Album, error) {
|
||||
func (c *metadataServiceClient) GetAlbum(ctx context.Context, in *GetAlbumRequest, opts ...grpc.CallOption) (*GetAlbumResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Album)
|
||||
out := new(GetAlbumResponse)
|
||||
err := c.cc.Invoke(ctx, MetadataService_GetAlbum_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,9 +91,9 @@ func (c *metadataServiceClient) GetArtistAlbums(ctx context.Context, in *GetArti
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *metadataServiceClient) GetTrack(ctx context.Context, in *GetTrackRequest, opts ...grpc.CallOption) (*Track, error) {
|
||||
func (c *metadataServiceClient) GetTrack(ctx context.Context, in *GetTrackRequest, opts ...grpc.CallOption) (*GetTrackResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Track)
|
||||
out := new(GetTrackResponse)
|
||||
err := c.cc.Invoke(ctx, MetadataService_GetTrack_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -144,24 +134,14 @@ func (c *metadataServiceClient) SyncArtist(ctx context.Context, in *SyncArtistRe
|
||||
// MetadataServiceServer is the server API for MetadataService service.
|
||||
// All implementations must embed UnimplementedMetadataServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// MetadataService provides music metadata aggregation.
|
||||
type MetadataServiceServer interface {
|
||||
// GetArtist retrieves an artist by ID or external source ID.
|
||||
GetArtist(context.Context, *GetArtistRequest) (*Artist, error)
|
||||
// SearchArtists searches for artists by name.
|
||||
GetArtist(context.Context, *GetArtistRequest) (*GetArtistResponse, error)
|
||||
SearchArtists(context.Context, *SearchArtistsRequest) (*SearchArtistsResponse, error)
|
||||
// GetAlbum retrieves an album by ID.
|
||||
GetAlbum(context.Context, *GetAlbumRequest) (*Album, error)
|
||||
// GetArtistAlbums retrieves all albums by an artist.
|
||||
GetAlbum(context.Context, *GetAlbumRequest) (*GetAlbumResponse, error)
|
||||
GetArtistAlbums(context.Context, *GetArtistAlbumsRequest) (*GetArtistAlbumsResponse, error)
|
||||
// GetTrack retrieves a track by ID.
|
||||
GetTrack(context.Context, *GetTrackRequest) (*Track, error)
|
||||
// GetAlbumTracks retrieves all tracks on an album.
|
||||
GetTrack(context.Context, *GetTrackRequest) (*GetTrackResponse, error)
|
||||
GetAlbumTracks(context.Context, *GetAlbumTracksRequest) (*GetAlbumTracksResponse, error)
|
||||
// SearchAlbums searches for albums by name, optionally filtered by artist.
|
||||
SearchAlbums(context.Context, *SearchAlbumsRequest) (*SearchAlbumsResponse, error)
|
||||
// SyncArtist triggers ingestion of an artist from external sources.
|
||||
SyncArtist(context.Context, *SyncArtistRequest) (*SyncArtistResponse, error)
|
||||
mustEmbedUnimplementedMetadataServiceServer()
|
||||
}
|
||||
@@ -173,19 +153,19 @@ type MetadataServiceServer interface {
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedMetadataServiceServer struct{}
|
||||
|
||||
func (UnimplementedMetadataServiceServer) GetArtist(context.Context, *GetArtistRequest) (*Artist, error) {
|
||||
func (UnimplementedMetadataServiceServer) GetArtist(context.Context, *GetArtistRequest) (*GetArtistResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetArtist not implemented")
|
||||
}
|
||||
func (UnimplementedMetadataServiceServer) SearchArtists(context.Context, *SearchArtistsRequest) (*SearchArtistsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SearchArtists not implemented")
|
||||
}
|
||||
func (UnimplementedMetadataServiceServer) GetAlbum(context.Context, *GetAlbumRequest) (*Album, error) {
|
||||
func (UnimplementedMetadataServiceServer) GetAlbum(context.Context, *GetAlbumRequest) (*GetAlbumResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetAlbum not implemented")
|
||||
}
|
||||
func (UnimplementedMetadataServiceServer) GetArtistAlbums(context.Context, *GetArtistAlbumsRequest) (*GetArtistAlbumsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetArtistAlbums not implemented")
|
||||
}
|
||||
func (UnimplementedMetadataServiceServer) GetTrack(context.Context, *GetTrackRequest) (*Track, error) {
|
||||
func (UnimplementedMetadataServiceServer) GetTrack(context.Context, *GetTrackRequest) (*GetTrackResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetTrack not implemented")
|
||||
}
|
||||
func (UnimplementedMetadataServiceServer) GetAlbumTracks(context.Context, *GetAlbumTracksRequest) (*GetAlbumTracksResponse, error) {
|
||||
|
||||
@@ -4,199 +4,341 @@ package metadata.v1;
|
||||
|
||||
option go_package = "github.com/metadata-agregator/pkg/gen/metadata/v1;metadatav1";
|
||||
|
||||
// Provider identifies the upstream metadata source a request is resolved
|
||||
// against. It also selects which ID namespace bare `id` fields refer to.
|
||||
enum Provider {
|
||||
// Unset. Requests default to the server's configured default provider.
|
||||
PROVIDER_UNSPECIFIED = 0;
|
||||
// MusicBrainz (https://musicbrainz.org). Native IDs are MBIDs (UUIDs).
|
||||
PROVIDER_MUSICBRAINZ = 1;
|
||||
}
|
||||
|
||||
// MetadataService provides music metadata aggregation.
|
||||
service MetadataService {
|
||||
// GetArtist retrieves an artist by ID or external source ID.
|
||||
rpc GetArtist(GetArtistRequest) returns (Artist);
|
||||
|
||||
// SearchArtists searches for artists by name.
|
||||
rpc GetArtist(GetArtistRequest) returns (GetArtistResponse);
|
||||
rpc SearchArtists(SearchArtistsRequest) returns (SearchArtistsResponse);
|
||||
|
||||
// GetAlbum retrieves an album by ID.
|
||||
rpc GetAlbum(GetAlbumRequest) returns (Album);
|
||||
|
||||
// GetArtistAlbums retrieves all albums by an artist.
|
||||
rpc GetAlbum(GetAlbumRequest) returns (GetAlbumResponse);
|
||||
rpc GetArtistAlbums(GetArtistAlbumsRequest) returns (GetArtistAlbumsResponse);
|
||||
|
||||
// GetTrack retrieves a track by ID.
|
||||
rpc GetTrack(GetTrackRequest) returns (Track);
|
||||
|
||||
// GetAlbumTracks retrieves all tracks on an album.
|
||||
rpc GetTrack(GetTrackRequest) returns (GetTrackResponse);
|
||||
rpc GetAlbumTracks(GetAlbumTracksRequest) returns (GetAlbumTracksResponse);
|
||||
|
||||
// SearchAlbums searches for albums by name, optionally filtered by artist.
|
||||
rpc SearchAlbums(SearchAlbumsRequest) returns (SearchAlbumsResponse);
|
||||
|
||||
// SyncArtist triggers ingestion of an artist from external sources.
|
||||
rpc SyncArtist(SyncArtistRequest) returns (SyncArtistResponse);
|
||||
}
|
||||
|
||||
// Requests
|
||||
|
||||
// GetArtistRequest looks up a single artist by one of its identifiers.
|
||||
message GetArtistRequest {
|
||||
// How to locate the artist. Exactly one field must be set.
|
||||
oneof identifier {
|
||||
string id = 1; // Internal UUID
|
||||
ExternalID external = 2; // External source ID (e.g., musicbrainz MBID)
|
||||
}
|
||||
Provider provider = 3; // UNSPECIFIED = query all providers
|
||||
}
|
||||
|
||||
message SearchArtistsRequest {
|
||||
string query = 1;
|
||||
int32 limit = 2;
|
||||
int32 offset = 3;
|
||||
Provider provider = 4;
|
||||
}
|
||||
|
||||
message GetAlbumRequest {
|
||||
oneof identifier {
|
||||
// Provider-native identifier for the artist, in the ID namespace of
|
||||
// `provider`. For PROVIDER_MUSICBRAINZ this is the artist MBID.
|
||||
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da" (Radiohead).
|
||||
string id = 1;
|
||||
// Identifier from a specific external source, when the source differs
|
||||
// from `provider` or must be named explicitly.
|
||||
// Example: {source: "musicbrainz", source_id: "5b11f4ce-..."}.
|
||||
ExternalID external = 2;
|
||||
}
|
||||
// Source to resolve against and the ID namespace of `id`.
|
||||
// Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 3;
|
||||
}
|
||||
|
||||
message GetArtistAlbumsRequest {
|
||||
string artist_id = 1;
|
||||
// SearchArtistsRequest performs a fuzzy, paginated artist search.
|
||||
message SearchArtistsRequest {
|
||||
// Free-text search terms, typically the artist name. Example: "radiohead".
|
||||
string query = 1;
|
||||
// Maximum number of results to return. Example: 25 (0 uses the default).
|
||||
int32 limit = 2;
|
||||
// Number of leading results to skip, for pagination. Example: 25.
|
||||
int32 offset = 3;
|
||||
// Source to search. Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 4;
|
||||
}
|
||||
|
||||
message GetTrackRequest {
|
||||
// GetAlbumRequest looks up a single album (release group) by identifier.
|
||||
message GetAlbumRequest {
|
||||
// How to locate the album. Exactly one field must be set.
|
||||
oneof identifier {
|
||||
// Provider-native album identifier, in the ID namespace of `provider`.
|
||||
// For PROVIDER_MUSICBRAINZ this is the release-group MBID.
|
||||
// Example: "b1392450-e666-3926-a536-22c65f834433" (OK Computer).
|
||||
string id = 1;
|
||||
// Identifier from a specific external source.
|
||||
// Example: {source: "musicbrainz", source_id: "b1392450-..."}.
|
||||
ExternalID external = 2;
|
||||
}
|
||||
// Source to resolve against. Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 3;
|
||||
}
|
||||
|
||||
// GetArtistAlbumsRequest lists an artist's albums, paginated and optionally
|
||||
// filtered by release type.
|
||||
message GetArtistAlbumsRequest {
|
||||
// Provider-native artist identifier (same namespace as GetArtistRequest.id).
|
||||
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da".
|
||||
string artist_id = 1;
|
||||
// Maximum number of albums to return. Example: 50.
|
||||
int32 limit = 2;
|
||||
// Number of leading albums to skip, for pagination. Example: 0.
|
||||
int32 offset = 3;
|
||||
// Source to query. Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 4;
|
||||
// Optional release-type filter; empty means all types.
|
||||
// Example: ["album", "ep"].
|
||||
repeated string album_types = 5;
|
||||
}
|
||||
|
||||
// GetTrackRequest looks up a single track (recording) by identifier.
|
||||
message GetTrackRequest {
|
||||
// How to locate the track. Exactly one field must be set.
|
||||
oneof identifier {
|
||||
// Provider-native track identifier, in the ID namespace of `provider`.
|
||||
// For PROVIDER_MUSICBRAINZ this is the recording MBID.
|
||||
// Example: "b9d99e0e-1472-4a37-8b2a-2f6b0a1234ab".
|
||||
string id = 1;
|
||||
// Identifier from a specific external source.
|
||||
// Example: {source: "musicbrainz", source_id: "b9d99e0e-..."}.
|
||||
ExternalID external = 2;
|
||||
// International Standard Recording Code. Example: "GBAYE9600477".
|
||||
string isrc = 3;
|
||||
}
|
||||
// Source to resolve against. Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 4;
|
||||
}
|
||||
|
||||
// GetAlbumTracksRequest lists the tracks belonging to an album.
|
||||
message GetAlbumTracksRequest {
|
||||
// Provider-native album identifier (same namespace as GetAlbumRequest.id).
|
||||
// Example: "b1392450-e666-3926-a536-22c65f834433".
|
||||
string album_id = 1;
|
||||
// Source to query. Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 2;
|
||||
}
|
||||
|
||||
// SearchAlbumsRequest performs a fuzzy, paginated album search.
|
||||
message SearchAlbumsRequest {
|
||||
// Free-text search terms, typically the album title. Example: "ok computer".
|
||||
string query = 1;
|
||||
// Optional artist name to narrow the search. Example: "radiohead".
|
||||
string artist = 2;
|
||||
// Maximum number of results to return. Example: 25.
|
||||
int32 limit = 3;
|
||||
// Number of leading results to skip, for pagination. Example: 0.
|
||||
int32 offset = 4;
|
||||
// Source to search. Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 5;
|
||||
// Optional release-type filter; empty means all types.
|
||||
// Example: ["album"].
|
||||
repeated string album_types = 6;
|
||||
}
|
||||
|
||||
message SearchAlbumsResponse {
|
||||
repeated Album albums = 1;
|
||||
int32 total = 2;
|
||||
}
|
||||
|
||||
// SyncArtistRequest fetches an artist and their catalog from a provider and
|
||||
// persists it to the local store.
|
||||
message SyncArtistRequest {
|
||||
// How to identify the artist to sync. Exactly one field must be set.
|
||||
oneof target {
|
||||
// Artist name to resolve via search before syncing. Example: "Radiohead".
|
||||
string name = 1;
|
||||
// Exact external identifier of the artist to sync.
|
||||
// Example: {source: "musicbrainz", source_id: "5b11f4ce-..."}.
|
||||
ExternalID external = 2;
|
||||
}
|
||||
// Source to sync from. Example: PROVIDER_MUSICBRAINZ.
|
||||
Provider provider = 3;
|
||||
}
|
||||
|
||||
// Responses
|
||||
|
||||
message GetArtistResponse {
|
||||
// The resolved artist.
|
||||
Artist artist = 1;
|
||||
}
|
||||
|
||||
message SearchArtistsResponse {
|
||||
// Matching artists, ordered by relevance.
|
||||
repeated Artist artists = 1;
|
||||
// Total number of matches available across all pages. Example: 137.
|
||||
int32 total = 2;
|
||||
}
|
||||
|
||||
message GetAlbumResponse {
|
||||
// The resolved album.
|
||||
Album album = 1;
|
||||
}
|
||||
|
||||
message GetArtistAlbumsResponse {
|
||||
// The artist's albums for the requested page.
|
||||
repeated Album albums = 1;
|
||||
// Total number of albums available across all pages. Example: 42.
|
||||
int32 total = 2;
|
||||
}
|
||||
|
||||
message GetTrackResponse {
|
||||
// The resolved track.
|
||||
Track track = 1;
|
||||
}
|
||||
|
||||
message GetAlbumTracksResponse {
|
||||
// The album's tracks, ordered by disc then track number.
|
||||
repeated Track tracks = 1;
|
||||
}
|
||||
|
||||
message SearchAlbumsResponse {
|
||||
// Matching albums, ordered by relevance.
|
||||
repeated Album albums = 1;
|
||||
// Total number of matches available across all pages. Example: 58.
|
||||
int32 total = 2;
|
||||
}
|
||||
|
||||
message SyncArtistResponse {
|
||||
// The synced artist as persisted locally.
|
||||
Artist artist = 1;
|
||||
// Number of albums fetched and stored during the sync. Example: 12.
|
||||
int32 albums_synced = 2;
|
||||
// Number of tracks fetched and stored during the sync. Example: 148.
|
||||
int32 tracks_synced = 3;
|
||||
}
|
||||
|
||||
// Core Entities
|
||||
|
||||
// Artist is a performer or group.
|
||||
message Artist {
|
||||
// Provider-native identifier (MBID for MusicBrainz).
|
||||
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da".
|
||||
string id = 1;
|
||||
// Display name. Example: "Radiohead".
|
||||
string name = 2;
|
||||
// Name normalized for alphabetical sorting. Example: "Radiohead"; for a
|
||||
// person, "Beatles, The" or "Hendrix, Jimi".
|
||||
string sort_name = 3;
|
||||
string artist_type = 4; // person, group, orchestra, etc.
|
||||
// Kind of artist. Example: "Group", "Person".
|
||||
string artist_type = 4;
|
||||
// ISO 3166-1 alpha-2 country code. Example: "GB".
|
||||
string country = 5;
|
||||
// Date the artist formed or was born, ISO 8601. Partial dates allowed.
|
||||
// Example: "1985" or "1985-05-01".
|
||||
string formed_date = 6;
|
||||
// Date the artist disbanded or died, ISO 8601; empty if still active.
|
||||
// Example: "2011-04-30".
|
||||
string disbanded_date = 7;
|
||||
// Free-text biography or annotation.
|
||||
string description = 8;
|
||||
// URL of a representative image. Example: "https://example.org/radiohead.jpg".
|
||||
string image_url = 9;
|
||||
// Genres associated with the artist.
|
||||
repeated Genre genres = 10;
|
||||
// Identifiers for this artist in external sources.
|
||||
repeated ExternalID external_ids = 11;
|
||||
}
|
||||
|
||||
// Album is a release group / collection of tracks.
|
||||
message Album {
|
||||
// Provider-native identifier (release-group MBID for MusicBrainz).
|
||||
// Example: "b1392450-e666-3926-a536-22c65f834433".
|
||||
string id = 1;
|
||||
// Album title. Example: "OK Computer".
|
||||
string title = 2;
|
||||
string album_type = 3; // album, ep, single, compilation
|
||||
// Release type. Example: "album", "ep", "single", "compilation".
|
||||
string album_type = 3;
|
||||
// Release date, ISO 8601. Partial dates allowed. Example: "1997-05-21".
|
||||
string release_date = 4;
|
||||
// Universal Product Code (barcode). Example: "724385522925".
|
||||
string upc = 5;
|
||||
// Number of tracks across all discs. Example: 12.
|
||||
int32 total_tracks = 6;
|
||||
// Number of discs in the release. Example: 1.
|
||||
int32 total_discs = 7;
|
||||
// URL of the cover art. Example: "https://coverartarchive.org/release/.../front".
|
||||
string cover_url = 8;
|
||||
// Credited artists, in credit order.
|
||||
repeated ArtistCredit artists = 9;
|
||||
// Releasing label, if known.
|
||||
Label label = 10;
|
||||
// Genres associated with the album.
|
||||
repeated Genre genres = 11;
|
||||
// Identifiers for this album in external sources.
|
||||
repeated ExternalID external_ids = 12;
|
||||
}
|
||||
|
||||
// Track is a recording as it appears on an album.
|
||||
message Track {
|
||||
// Provider-native identifier (recording MBID for MusicBrainz).
|
||||
// Example: "b9d99e0e-1472-4a37-8b2a-2f6b0a1234ab".
|
||||
string id = 1;
|
||||
// Track title. Example: "Paranoid Android".
|
||||
string title = 2;
|
||||
// Playing time in milliseconds. Example: 383000 (6:23).
|
||||
int32 duration_ms = 3;
|
||||
// International Standard Recording Code. Example: "GBAYE9600477".
|
||||
string isrc = 4;
|
||||
// Whether the track is flagged as explicit.
|
||||
bool explicit = 5;
|
||||
// 1-based disc number the track appears on. Example: 1.
|
||||
int32 disc_number = 6;
|
||||
// 1-based position of the track within its disc. Example: 2.
|
||||
int32 track_number = 7;
|
||||
// Credited performers, in credit order.
|
||||
repeated ArtistCredit artists = 8;
|
||||
// Underlying composition, if linked.
|
||||
Work work = 9;
|
||||
// Identifiers for this track in external sources.
|
||||
repeated ExternalID external_ids = 10;
|
||||
}
|
||||
|
||||
// Work is the underlying composition a track recording is based on.
|
||||
message Work {
|
||||
// Provider-native identifier (work MBID for MusicBrainz).
|
||||
string id = 1;
|
||||
// Work title. Example: "Paranoid Android".
|
||||
string title = 2;
|
||||
// Kind of work. Example: "Song".
|
||||
string work_type = 3;
|
||||
// ISO 639-3 language code of the lyrics. Example: "eng".
|
||||
string language = 4;
|
||||
// Credited composers and lyricists.
|
||||
repeated ArtistCredit composers = 5;
|
||||
}
|
||||
|
||||
// Label is a record label.
|
||||
message Label {
|
||||
// Provider-native identifier (label MBID for MusicBrainz).
|
||||
string id = 1;
|
||||
// Label name. Example: "Parlophone".
|
||||
string name = 2;
|
||||
// ISO 3166-1 alpha-2 country code. Example: "GB".
|
||||
string country = 3;
|
||||
}
|
||||
|
||||
// Genre is a musical genre tag.
|
||||
message Genre {
|
||||
// Provider-native identifier (genre MBID for MusicBrainz).
|
||||
string id = 1;
|
||||
// Genre name. Example: "alternative rock".
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
// ArtistCredit links an artist to a release or track, preserving how the
|
||||
// artist is credited (role, order, and connecting text).
|
||||
message ArtistCredit {
|
||||
// The credited artist.
|
||||
Artist artist = 1;
|
||||
string role = 2; // primary, featured, remixer, producer
|
||||
// Role in this credit. Example: "main", "featured", "composer".
|
||||
string role = 2;
|
||||
// 0-based position within the credit list. Example: 0.
|
||||
int32 position = 3;
|
||||
string join_phrase = 4; // " & ", " feat. ", etc.
|
||||
// Text joining this credit to the next. Example: " feat. " or " & ".
|
||||
string join_phrase = 4;
|
||||
}
|
||||
|
||||
// ExternalID identifies an entity within a third-party source.
|
||||
message ExternalID {
|
||||
string source = 1; // musicbrainz, spotify, discogs, etc.
|
||||
// Source name. Example: "musicbrainz", "spotify", "discogs".
|
||||
string source = 1;
|
||||
// The entity's identifier within that source.
|
||||
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da".
|
||||
string source_id = 2;
|
||||
// Canonical URL of the entity at the source, if available.
|
||||
// Example: "https://musicbrainz.org/artist/5b11f4ce-...".
|
||||
string url = 3;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/metadata-agregator/internal/provider/musicbrainz"
|
||||
"github.com/metadata-agregator/internal/server"
|
||||
"github.com/metadata-agregator/internal/service"
|
||||
metadatav1 "github.com/metadata-agregator/pkg/gen/metadata/v1"
|
||||
)
|
||||
|
||||
@@ -32,8 +34,17 @@ func startTestServer(t *testing.T) *testServer {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
|
||||
mb := musicbrainz.New()
|
||||
noopArtist := &noopArtistRepo{}
|
||||
noopAlbum := &noopAlbumRepo{}
|
||||
noopTrack := &noopTrackRepo{}
|
||||
svc := service.NewMetadataService(noopArtist, noopAlbum, noopTrack, mb)
|
||||
services := map[metadatav1.Provider]*service.MetadataService{
|
||||
metadatav1.Provider_PROVIDER_MUSICBRAINZ: svc,
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
metadatav1.RegisterMetadataServiceServer(grpcServer, server.NewMetadataServer())
|
||||
metadatav1.RegisterMetadataServiceServer(grpcServer, server.NewMetadataServer(services))
|
||||
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
@@ -119,13 +130,14 @@ func TestGetArtist(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
artist, err := client.GetArtist(ctx, &metadatav1.GetArtistRequest{
|
||||
resp, err := client.GetArtist(ctx, &metadatav1.GetArtistRequest{
|
||||
Identifier: &metadatav1.GetArtistRequest_Id{Id: radioheadMBID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtist failed: %v", err)
|
||||
}
|
||||
|
||||
artist := resp.Artist
|
||||
if artist.Id != radioheadMBID {
|
||||
t.Errorf("expected ID %q, got %q", radioheadMBID, artist.Id)
|
||||
}
|
||||
@@ -223,13 +235,14 @@ func TestGetAlbum(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
album, err := client.GetAlbum(ctx, &metadatav1.GetAlbumRequest{
|
||||
albumResp, err := client.GetAlbum(ctx, &metadatav1.GetAlbumRequest{
|
||||
Identifier: &metadatav1.GetAlbumRequest_Id{Id: okComputerMBID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbum failed: %v", err)
|
||||
}
|
||||
|
||||
album := albumResp.Album
|
||||
if album.Id != okComputerMBID {
|
||||
t.Errorf("expected ID %q, got %q", okComputerMBID, album.Id)
|
||||
}
|
||||
@@ -324,13 +337,14 @@ func TestGetTrack(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
track, err := client.GetTrack(ctx, &metadatav1.GetTrackRequest{
|
||||
trackResp, err := client.GetTrack(ctx, &metadatav1.GetTrackRequest{
|
||||
Identifier: &metadatav1.GetTrackRequest_Id{Id: paranoidAndroid},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack failed: %v", err)
|
||||
}
|
||||
|
||||
track := trackResp.Track
|
||||
if track.Id != paranoidAndroid {
|
||||
t.Errorf("expected ID %q, got %q", paranoidAndroid, track.Id)
|
||||
}
|
||||
@@ -364,15 +378,15 @@ func TestGetTrackByISRC(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
track, err := client.GetTrack(ctx, &metadatav1.GetTrackRequest{
|
||||
isrcResp, err := client.GetTrack(ctx, &metadatav1.GetTrackRequest{
|
||||
Identifier: &metadatav1.GetTrackRequest_Isrc{Isrc: "GBAYE9701376"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack by ISRC failed: %v", err)
|
||||
}
|
||||
|
||||
if track.Title != "Paranoid Android" {
|
||||
t.Errorf("expected title 'Paranoid Android', got %q", track.Title)
|
||||
if isrcResp.Track.Title != "Paranoid Android" {
|
||||
t.Errorf("expected title 'Paranoid Android', got %q", isrcResp.Track.Title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
)
|
||||
|
||||
type noopArtistRepo struct{}
|
||||
|
||||
func (r *noopArtistRepo) GetByID(ctx context.Context, id string) (*domain.Artist, error) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *noopArtistRepo) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Artist, error) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *noopArtistRepo) Search(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error) {
|
||||
return &domain.SearchResult[domain.Artist]{}, nil
|
||||
}
|
||||
|
||||
func (r *noopArtistRepo) Save(ctx context.Context, artist *domain.Artist) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopArtistRepo) SaveAll(ctx context.Context, artists []domain.Artist) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noopAlbumRepo struct{}
|
||||
|
||||
func (r *noopAlbumRepo) GetByID(ctx context.Context, id string) (*domain.Album, error) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Album, error) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) GetByArtistID(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
return &domain.SearchResult[domain.Album]{}, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) Save(ctx context.Context, album *domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) SaveAll(ctx context.Context, albums []domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noopTrackRepo struct{}
|
||||
|
||||
func (r *noopTrackRepo) GetByID(ctx context.Context, id string) (*domain.Track, error) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *noopTrackRepo) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Track, error) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *noopTrackRepo) GetByISRC(ctx context.Context, isrc string) (*domain.Track, error) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *noopTrackRepo) GetByAlbumID(ctx context.Context, albumID string) ([]domain.Track, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *noopTrackRepo) Save(ctx context.Context, track *domain.Track) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopTrackRepo) SaveAlbumTracks(ctx context.Context, albumID string, tracks []domain.Track) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user