Compare commits

...

10 Commits

Author SHA1 Message Date
Alexander 995397e495 Something + add comments to proto 2026-07-12 10:16:54 +02:00
Alexander fde1b4cd3e feat: add NixOS module with schema init
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 12:38:16 +02:00
Alexander 2756cc1043 feat: fuzzy search with parallel DB/MusicBrainz execution
- Add pg_trgm fuzzy search to PostgreSQL artist repository
- Add Lucene fuzzy query (~0.7) for MusicBrainz artist search
- Run DB and MusicBrainz searches in parallel goroutines
- Cache MusicBrainz results to DB for accumulation
- Deduplicate results by external ID
- Filter out special purpose artists (type=Other)
- Add SaveAll method for batch artist inserts
- Move database schema to containers repo
2026-05-10 21:13:17 +02:00
Alexander b4e763be44 perf: fix N+1 query in GetAllByArtistID with batch loading
Replace per-album loadRelations calls with batch queries using ANY($1).
Reduces 1+2N queries to 3 queries total (1.45s → 50ms for 83 albums).
2026-05-10 00:03:29 +02:00
Alexander 88372c9301 feat: cache album tracks and load album artists on album fetch
- Add SaveAlbumTracks to TrackRepository for batch track caching
- Extend album loadRelations to join album_artists with artist data
- Cache album tracks in GetAlbumTracks after provider fetch
- Ensure artists cached when fetching single album
2026-05-09 19:28:44 +02:00
Alexander 9f1318e79e feat: fetch all artist albums with caching, filter by type in handler
- Provider fetches all release groups via paginated browse, no type filter
- Service caches all albums in DB, ensures referenced artists exist first
- Server handler filters by album_types (default: album|ep|single), excludes secondary types
- Add secondary_types to domain, DB schema, and MB types
- Add ErrBadRequest handling for MusicBrainz 400 errors
- Replace migrations with single schema.sql
- Add GetAllByArtistID and SaveAll to album repository
2026-05-07 20:03:04 +02:00
Alexander 35ac167952 feat: filter artist albums by type, default to album|ep|single 2026-05-07 17:22:59 +02:00
Alexander 80051396d8 refactor: split monolithic HTTP client into focused methods 2026-05-07 17:16:32 +02:00
Alexander 54d94b939e Refactor the proto 2026-05-07 17:02:46 +02:00
Alexander 3d111f9008 Refactor logging 2026-05-07 16:50:40 +02:00
43 changed files with 2630 additions and 785 deletions
+8 -1
View File
@@ -1 +1,8 @@
use flake
#!/usr/bin/env bash
export GIT_CONFIG_GLOBAL=/dev/null
eval "$(devenv direnvrc)"
# You can pass flags to the devenv command
# For example: use devenv --impure --option services.postgres.enable:bool true
use devenv
+5
View File
@@ -12,3 +12,8 @@ vendor/
docs/research/*/repo/
docs/research/*/repo-index/
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
+49 -19
View File
@@ -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()
}
+16
View File
@@ -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
View File
@@ -8,3 +8,11 @@ database:
password: metadata
name: metadata
sslmode: disable
logging:
level: info
format: json
metrics:
enabled: true
port: 9090
-23
View File
@@ -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;
-10
View File
@@ -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
+17
View File
@@ -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
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-88
View File
@@ -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
];
};
};
};
}
+19 -5
View File
@@ -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
)
+65 -14
View File
@@ -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=
+20
View File
@@ -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
View File
@@ -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 {
+137
View File
@@ -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
}
+21
View File
@@ -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()
}
+115
View File
@@ -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")
}
}
+135 -31
View File
@@ -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 {
+1
View File
@@ -4,5 +4,6 @@ import "errors"
var (
ErrNotFound = errors.New("not found")
ErrBadRequest = errors.New("bad request")
ErrRateLimited = errors.New("rate limited")
)
+4 -3
View File
@@ -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,
+52 -27
View File
@@ -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
+24 -23
View File
@@ -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 {
+1 -1
View File
@@ -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)
+230 -17
View File
@@ -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()
}
+129 -28
View File
@@ -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`
+61
View File
@@ -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...)
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
+15 -35
View File
@@ -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) {
+189 -47
View File
@@ -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;
}
+21 -7
View File
@@ -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)
}
}
+82
View File
@@ -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
}