diff --git a/containers/init.sql b/containers/init.sql deleted file mode 100644 index 83bd92b..0000000 --- a/containers/init.sql +++ /dev/null @@ -1,225 +0,0 @@ --- Music Aggregator Database Schema --- Based on docs/erd.puml - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ══════════════════════════════════════════════════════════════ --- CONFIGURATION --- ══════════════════════════════════════════════════════════════ - -CREATE TABLE quality_profiles ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL UNIQUE, - cutoff INT NOT NULL DEFAULT 0, - items JSONB NOT NULL DEFAULT '[]', - upgrade_allowed BOOLEAN NOT NULL DEFAULT true -); - -CREATE TABLE metadata_profiles ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL UNIQUE, - primary_album_types JSONB NOT NULL DEFAULT '["Album", "EP"]', - secondary_album_types JSONB NOT NULL DEFAULT '[]', - release_statuses JSONB NOT NULL DEFAULT '["Official"]' -); - -CREATE TABLE root_folders ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL, - path TEXT NOT NULL UNIQUE, - default_quality_profile_id UUID REFERENCES quality_profiles(id), - default_metadata_profile_id UUID REFERENCES metadata_profiles(id) -); - -CREATE TABLE indexers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL, - implementation TEXT NOT NULL, - settings JSONB NOT NULL DEFAULT '{}', - enable_rss BOOLEAN NOT NULL DEFAULT true, - enable_search BOOLEAN NOT NULL DEFAULT true, - priority INT NOT NULL DEFAULT 25 -); - -CREATE TABLE download_clients ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL, - implementation TEXT NOT NULL, - settings JSONB NOT NULL DEFAULT '{}', - protocol TEXT NOT NULL DEFAULT 'torrent', - priority INT NOT NULL DEFAULT 1, - enabled BOOLEAN NOT NULL DEFAULT true -); - --- ══════════════════════════════════════════════════════════════ --- CORE MUSIC ENTITIES --- ══════════════════════════════════════════════════════════════ - -CREATE TABLE artist_metadata ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - foreign_artist_id TEXT UNIQUE, - name TEXT NOT NULL, - sort_name TEXT, - disambiguation TEXT, - artist_type TEXT, - status TEXT, - overview TEXT, - images JSONB NOT NULL DEFAULT '[]', - links JSONB NOT NULL DEFAULT '[]', - genres JSONB NOT NULL DEFAULT '[]', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE artists ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE, - quality_profile_id UUID REFERENCES quality_profiles(id), - metadata_profile_id UUID REFERENCES metadata_profiles(id), - root_folder_id UUID REFERENCES root_folders(id), - path TEXT, - monitored BOOLEAN NOT NULL DEFAULT true, - monitor_new_items TEXT NOT NULL DEFAULT 'all', - last_info_sync TIMESTAMPTZ, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE albums ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE, - foreign_album_id TEXT UNIQUE, - title TEXT NOT NULL, - clean_title TEXT, - disambiguation TEXT, - overview TEXT, - album_type TEXT, - release_date DATE, - images JSONB NOT NULL DEFAULT '[]', - genres JSONB NOT NULL DEFAULT '[]', - monitored BOOLEAN NOT NULL DEFAULT true, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE album_releases ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE, - foreign_release_id TEXT UNIQUE, - title TEXT NOT NULL, - status TEXT, - duration_ms INT, - release_date DATE, - country TEXT[], - label TEXT[], - format TEXT, - track_count INT, - monitored BOOLEAN NOT NULL DEFAULT true -); - -CREATE TABLE track_files ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE, - path TEXT NOT NULL, - relative_path TEXT NOT NULL, - size BIGINT NOT NULL DEFAULT 0, - file_hash TEXT, - audio_hash TEXT, - quality JSONB NOT NULL DEFAULT '{}', - media_info JSONB NOT NULL DEFAULT '{}', - scene_name TEXT, - release_group TEXT, - date_added TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE tracks ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - album_release_id UUID NOT NULL REFERENCES album_releases(id) ON DELETE CASCADE, - artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE, - track_file_id UUID REFERENCES track_files(id) ON DELETE SET NULL, - foreign_track_id TEXT UNIQUE, - title TEXT NOT NULL, - track_number INT NOT NULL DEFAULT 1, - disc_number INT NOT NULL DEFAULT 1, - duration_ms INT, - explicit BOOLEAN NOT NULL DEFAULT false -); - --- ══════════════════════════════════════════════════════════════ --- DOWNLOAD TRACKING --- ══════════════════════════════════════════════════════════════ - -CREATE TABLE wanted_albums ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - album_id UUID NOT NULL UNIQUE REFERENCES albums(id) ON DELETE CASCADE, - priority INT NOT NULL DEFAULT 0, - search_count INT NOT NULL DEFAULT 0, - last_searched_at TIMESTAMPTZ, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE download_queue ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - artist_id UUID REFERENCES artists(id) ON DELETE SET NULL, - album_id UUID REFERENCES albums(id) ON DELETE SET NULL, - download_id TEXT, - title TEXT NOT NULL, - size BIGINT NOT NULL DEFAULT 0, - size_left BIGINT NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'queued', - progress REAL NOT NULL DEFAULT 0.0, - error_message TEXT, - protocol TEXT NOT NULL DEFAULT 'torrent', - indexer TEXT, - download_client TEXT, - torrent_hash TEXT, - output_path TEXT, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - completed_at TIMESTAMPTZ -); - -CREATE TABLE blocklist ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE, - album_id UUID REFERENCES albums(id) ON DELETE CASCADE, - source_title TEXT NOT NULL, - quality JSONB NOT NULL DEFAULT '{}', - size BIGINT NOT NULL DEFAULT 0, - protocol TEXT, - indexer TEXT, - message TEXT, - torrent_hash TEXT, - date TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ══════════════════════════════════════════════════════════════ --- INDEXES --- ══════════════════════════════════════════════════════════════ - -CREATE INDEX idx_artist_metadata_name ON artist_metadata(name); -CREATE INDEX idx_artist_metadata_foreign_id ON artist_metadata(foreign_artist_id); -CREATE INDEX idx_albums_artist ON albums(artist_metadata_id); -CREATE INDEX idx_albums_foreign_id ON albums(foreign_album_id); -CREATE INDEX idx_albums_release_date ON albums(release_date); -CREATE INDEX idx_album_releases_album ON album_releases(album_id); -CREATE INDEX idx_tracks_release ON tracks(album_release_id); -CREATE INDEX idx_tracks_artist ON tracks(artist_metadata_id); -CREATE INDEX idx_track_files_album ON track_files(album_id); -CREATE INDEX idx_track_files_hash ON track_files(file_hash); -CREATE INDEX idx_track_files_audio_hash ON track_files(audio_hash); -CREATE INDEX idx_wanted_albums_priority ON wanted_albums(priority DESC); -CREATE INDEX idx_download_queue_status ON download_queue(status); -CREATE INDEX idx_download_queue_album ON download_queue(album_id); -CREATE INDEX idx_blocklist_artist ON blocklist(artist_id); -CREATE INDEX idx_blocklist_torrent ON blocklist(torrent_hash); - --- ══════════════════════════════════════════════════════════════ --- DEFAULT DATA --- ══════════════════════════════════════════════════════════════ - -INSERT INTO quality_profiles (name, cutoff, items, upgrade_allowed) VALUES -('Any', 0, '[]', true), -('Lossless', 1, '[{"quality": "FLAC", "allowed": true}, {"quality": "ALAC", "allowed": true}]', true), -('Standard', 2, '[{"quality": "MP3-320", "allowed": true}, {"quality": "MP3-VBR-V0", "allowed": true}]', true); - -INSERT INTO metadata_profiles (name, primary_album_types, secondary_album_types, release_statuses) VALUES -('Standard', '["Album", "EP"]', '[]', '["Official"]'), -('All', '["Album", "EP", "Single", "Broadcast", "Other"]', '["Compilation", "Soundtrack", "Spokenword", "Interview", "Audiobook", "Live", "Remix", "DJ-mix", "Mixtape/Street", "Demo"]', '["Official", "Promotional", "Bootleg"]'); diff --git a/internal/database/artist_repository.go b/internal/database/artist_repository.go index 15466d2..2c85059 100644 --- a/internal/database/artist_repository.go +++ b/internal/database/artist_repository.go @@ -8,16 +8,25 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +type ArtistMonitorState string + +const ( + ArtistMonitored ArtistMonitorState = "monitored" + ArtistUnmonitored ArtistMonitorState = "unmonitored" + ArtistExcluded ArtistMonitorState = "excluded" +) + type Artist struct { - ID string - ExternalID string - Name string - ArtistType string - Country string - Genres []string - ImageURL string - CreatedAt time.Time - UpdatedAt time.Time + ID string + ExternalID string + Name string + ArtistType string + Country string + Genres []string + ImageURL string + MonitorState ArtistMonitorState + CreatedAt time.Time + UpdatedAt time.Time } type ArtistRepository struct { @@ -30,17 +39,21 @@ func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository { func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error { _, err := r.pool.Exec(ctx, - `INSERT INTO artists (external_id, name, artist_type, country, genres, image_url) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (external_id) DO UPDATE SET name = EXCLUDED.name, artist_type = EXCLUDED.artist_type, country = EXCLUDED.country, genres = EXCLUDED.genres, image_url = EXCLUDED.image_url, + monitor_state = CASE + WHEN artists.monitor_state = 'excluded' THEN artists.monitor_state + ELSE EXCLUDED.monitor_state + END, updated_at = NOW() RETURNING id, created_at, updated_at`, - a.ExternalID, a.Name, a.ArtistType, a.Country, a.Genres, a.ImageURL, + a.ExternalID, a.Name, a.ArtistType, a.Country, a.Genres, a.ImageURL, a.MonitorState, ) if err != nil { return fmt.Errorf("creating artist: %w", err) @@ -51,21 +64,42 @@ func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error { func (r *ArtistRepository) GetByExternalID(ctx context.Context, externalID string) (*Artist, error) { a := &Artist{} err := r.pool.QueryRow(ctx, - `SELECT id, external_id, name, artist_type, country, genres, image_url, created_at, updated_at + `SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at FROM artists WHERE external_id = $1`, externalID, - ).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.CreatedAt, &a.UpdatedAt) + ).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, fmt.Errorf("getting artist: %w", err) } return a, nil } +func (r *ArtistRepository) GetAll(ctx context.Context) ([]*Artist, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at + FROM artists ORDER BY name ASC`, + ) + if err != nil { + return nil, fmt.Errorf("listing artists: %w", err) + } + defer rows.Close() + + var artists []*Artist + for rows.Next() { + a := &Artist{} + if err := rows.Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning artist: %w", err) + } + artists = append(artists, a) + } + return artists, nil +} + func (r *ArtistRepository) GetByID(ctx context.Context, id string) (*Artist, error) { a := &Artist{} err := r.pool.QueryRow(ctx, - `SELECT id, external_id, name, artist_type, country, genres, image_url, created_at, updated_at + `SELECT id, external_id, name, artist_type, country, genres, image_url, monitor_state, created_at, updated_at FROM artists WHERE id = $1`, id, - ).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.CreatedAt, &a.UpdatedAt) + ).Scan(&a.ID, &a.ExternalID, &a.Name, &a.ArtistType, &a.Country, &a.Genres, &a.ImageURL, &a.MonitorState, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, fmt.Errorf("getting artist: %w", err) } diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 83e77bc..b365e32 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -62,12 +62,13 @@ func (s *MetadataService) persistArtist(ctx context.Context, album *metadataPb.A } err := s.artists.Create(ctx, &database.Artist{ - ExternalID: artist.GetId(), - Name: artist.GetName(), - ArtistType: artist.GetArtistType(), - Country: artist.GetCountry(), - Genres: genres, - ImageURL: artist.GetImageUrl(), + ExternalID: artist.GetId(), + Name: artist.GetName(), + ArtistType: artist.GetArtistType(), + Country: artist.GetCountry(), + Genres: genres, + ImageURL: artist.GetImageUrl(), + MonitorState: database.ArtistMonitored, }) if err != nil { log.Warn().Err(err).Str("name", artist.GetName()).Msg("failed to persist artist") diff --git a/internal/server.go b/internal/server.go index 8caa3b5..08d0af6 100644 --- a/internal/server.go +++ b/internal/server.go @@ -29,6 +29,10 @@ func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx }, nil } +func (s *MusicAgregatorServer) GetArtists(ctx context.Context, req *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) { + return s.service.GetArtists(ctx, req) +} + func (s *MusicAgregatorServer) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) { return s.service.MonitorAlbum(ctx, req) } diff --git a/internal/service.go b/internal/service.go index 1a56f99..aa1493c 100644 --- a/internal/service.go +++ b/internal/service.go @@ -40,6 +40,7 @@ type MusicAgregatorService struct { riverClient *river.Client[pgx.Tx] torrents *database.TorrentRepository downloads *database.DownloadRepository + artists *database.ArtistRepository } func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorService, error) { @@ -76,6 +77,7 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T riverClient: riverClient, torrents: database.NewTorrentRepository(db.Pool), downloads: database.NewDownloadRepository(db.Pool), + artists: database.NewArtistRepository(db.Pool), }, nil } @@ -85,6 +87,30 @@ func (s *MusicAgregatorService) Close() { } } +func (service *MusicAgregatorService) GetArtists(ctx context.Context, _ *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) { + dbArtists, err := service.artists.GetAll(ctx) + if err != nil { + log.Error().Err(err).Msg("failed to list artists") + return nil, fmt.Errorf("listing artists: %w", err) + } + + artists := make([]*pb.ArtistSummary, 0, len(dbArtists)) + for _, a := range dbArtists { + artists = append(artists, &pb.ArtistSummary{ + Id: a.ID, + ExternalId: a.ExternalID, + Name: a.Name, + ArtistType: a.ArtistType, + Country: a.Country, + Genres: a.Genres, + ImageUrl: a.ImageURL, + MonitorState: toProtoMonitorState(a.MonitorState), + }) + } + + return &pb.GetArtistsResponse{Artists: artists}, nil +} + func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) { album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId()) if err != nil { @@ -378,6 +404,19 @@ func buildMonitoredRelease(p parsedItem) *pb.MonitoredRelease { } } +func toProtoMonitorState(state database.ArtistMonitorState) pb.ArtistMonitorState { + switch state { + case database.ArtistMonitored: + return pb.ArtistMonitorState_ARTIST_MONITOR_STATE_MONITORED + case database.ArtistUnmonitored: + return pb.ArtistMonitorState_ARTIST_MONITOR_STATE_UNMONITORED + case database.ArtistExcluded: + return pb.ArtistMonitorState_ARTIST_MONITOR_STATE_EXCLUDED + default: + return pb.ArtistMonitorState_ARTIST_MONITOR_STATE_UNSPECIFIED + } +} + func downloadTorrentData(url string) ([]byte, error) { client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(url) diff --git a/proto/music_agregator/v1/music_agregator.proto b/proto/music_agregator/v1/music_agregator.proto index c26e74d..818f620 100644 --- a/proto/music_agregator/v1/music_agregator.proto +++ b/proto/music_agregator/v1/music_agregator.proto @@ -4,6 +4,7 @@ option go_package = "homelab.lan/music-agregator/gen/music_agregator/v1/"; service MusicAgregatorService { rpc MonitorAlbum(MonitorAlbumRequest) returns (MonitorAlbumResponse) {} + rpc GetArtists(GetArtistsRequest) returns (GetArtistsResponse) {} } message MonitorAlbumRequest { @@ -26,6 +27,30 @@ message MonitorAlbumResponse { MonitoredRelease release = 1; } +message GetArtistsRequest {} + +message GetArtistsResponse { + repeated ArtistSummary artists = 1; +} + +enum ArtistMonitorState { + ARTIST_MONITOR_STATE_UNSPECIFIED = 0; + ARTIST_MONITOR_STATE_MONITORED = 1; + ARTIST_MONITOR_STATE_UNMONITORED = 2; + ARTIST_MONITOR_STATE_EXCLUDED = 3; +} + +message ArtistSummary { + string id = 1; + string external_id = 2; + string name = 3; + string artist_type = 4; + string country = 5; + repeated string genres = 6; + string image_url = 7; + ArtistMonitorState monitor_state = 8; +} + message MonitoredRelease { string info_hash = 1; string artist = 2;