From 66264e13143c8aa8184ece35ec3c85b073219342 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 8 May 2026 10:03:28 +0200 Subject: [PATCH] Add database schema, ERD, and repository layer --- docs/erd.puml | 322 +++++++----------- internal/database/album_repository.go | 132 +++++++ internal/database/artist_repository.go | 73 ++++ internal/database/db.go | 32 ++ internal/database/download_file_repository.go | 104 ++++++ internal/database/download_repository.go | 122 +++++++ internal/database/torrent_repository.go | 104 ++++++ internal/database/track_repository.go | 68 ++++ 8 files changed, 749 insertions(+), 208 deletions(-) create mode 100644 internal/database/album_repository.go create mode 100644 internal/database/artist_repository.go create mode 100644 internal/database/db.go create mode 100644 internal/database/download_file_repository.go create mode 100644 internal/database/download_repository.go create mode 100644 internal/database/torrent_repository.go create mode 100644 internal/database/track_repository.go diff --git a/docs/erd.puml b/docs/erd.puml index eac8539..f5bdded 100644 --- a/docs/erd.puml +++ b/docs/erd.puml @@ -16,259 +16,165 @@ skinparam package { title Music Aggregator - Database Structure -' ══════════════════════════════════════════════════════════════ -' CORE MUSIC ENTITIES -' ══════════════════════════════════════════════════════════════ - -package "Core Music Entities" #E3F2FD { - entity "artist_metadata" { +package "Music Metadata" #E3F2FD { + entity "artists" { * id : UUID <> -- - foreign_artist_id : TEXT <> - name : TEXT - sort_name : TEXT - disambiguation : TEXT - artist_type : TEXT - status : TEXT - overview : TEXT - images : JSONB - links : JSONB - genres : JSONB + external_id : VARCHAR(255) <> + name : VARCHAR(500) + artist_type : VARCHAR(50) + country : VARCHAR(10) + genres : TEXT[] + image_url : TEXT -- created_at : TIMESTAMPTZ updated_at : TIMESTAMPTZ } - entity "artists" { - * id : UUID <> - -- - metadata_id : UUID <> - quality_profile_id : UUID <> - metadata_profile_id : UUID <> - root_folder_id : UUID <> - -- - path : TEXT - monitored : BOOLEAN - monitor_new_items : TEXT - -- - last_info_sync : TIMESTAMPTZ - added_at : TIMESTAMPTZ - } - entity "albums" { * id : UUID <> -- - artist_metadata_id : UUID <> + external_id : VARCHAR(255) <> + artist_id : UUID <> -- - foreign_album_id : TEXT <> - title : TEXT - clean_title : TEXT - disambiguation : TEXT - overview : TEXT - album_type : TEXT + title : VARCHAR(500) + album_type : VARCHAR(50) release_date : DATE - images : JSONB - genres : JSONB + total_tracks : INT + total_discs : INT + label : VARCHAR(255) + genres : TEXT[] + cover_url : TEXT + is_monitored : BOOLEAN -- - monitored : BOOLEAN - added_at : TIMESTAMPTZ - } - - entity "album_releases" { - * id : UUID <> - -- - album_id : UUID <> - -- - foreign_release_id : TEXT <> - title : TEXT - status : TEXT - duration_ms : INT - release_date : DATE - country : TEXT[] - label : TEXT[] - format : TEXT - track_count : INT - -- - monitored : BOOLEAN + created_at : TIMESTAMPTZ + updated_at : TIMESTAMPTZ } entity "tracks" { * id : UUID <> -- - album_release_id : UUID <> - artist_metadata_id : UUID <> - track_file_id : UUID <> + external_id : VARCHAR(255) <> + album_id : UUID <> -- - foreign_track_id : TEXT <> - title : TEXT - track_number : INT - disc_number : INT + title : VARCHAR(500) duration_ms : INT - explicit : BOOLEAN - } - - entity "track_files" { - * id : UUID <> + isrc : VARCHAR(20) + disc_number : INT + track_number : INT -- - album_id : UUID <> - -- - path : TEXT - relative_path : TEXT - size : BIGINT - -- - file_hash : TEXT - audio_hash : TEXT - -- - quality : JSONB - media_info : JSONB - -- - scene_name : TEXT - release_group : TEXT - -- - date_added : TIMESTAMPTZ + created_at : TIMESTAMPTZ } } -' ══════════════════════════════════════════════════════════════ -' CONFIGURATION -' ══════════════════════════════════════════════════════════════ - -package "Configuration" #FFF3E0 { - entity "quality_profiles" { +package "Torrent Catalog" #FFF3E0 { + entity "torrents" { * id : UUID <> -- - name : TEXT <> - cutoff : INT - items : JSONB - upgrade_allowed : BOOLEAN - } - - entity "metadata_profiles" { - * id : UUID <> - -- - name : TEXT <> - primary_album_types : JSONB - secondary_album_types : JSONB - release_statuses : JSONB - } - - entity "root_folders" { - * id : UUID <> - -- - name : TEXT - path : TEXT <> - default_quality_profile_id : UUID <> - default_metadata_profile_id : UUID <> - } - - entity "indexers" { - * id : UUID <> - -- - name : TEXT - implementation : TEXT - settings : JSONB - enable_rss : BOOLEAN - enable_search : BOOLEAN - priority : INT - } - - entity "download_clients" { - * id : UUID <> - -- - name : TEXT - implementation : TEXT - settings : JSONB - protocol : TEXT - priority : INT - enabled : BOOLEAN - } -} - -' ══════════════════════════════════════════════════════════════ -' DOWNLOAD TRACKING -' ══════════════════════════════════════════════════════════════ - -package "Download Tracking" #E8F5E9 { - entity "wanted_albums" { - * id : UUID <> - -- - album_id : UUID <> <> - -- - priority : INT - search_count : INT - last_searched_at : TIMESTAMPTZ - added_at : TIMESTAMPTZ - } - - entity "download_queue" { - * id : UUID <> - -- - artist_id : UUID <> album_id : UUID <> + info_hash : VARCHAR(40) <> -- - download_id : TEXT + tracker : VARCHAR(100) title : TEXT + format : VARCHAR(20) + quality : VARCHAR(20) + source : VARCHAR(20) + bit_depth : INT + sample_rate : INT + seeders : INT + peers : INT size : BIGINT - size_left : BIGINT + track_count : INT + has_cover_art : BOOLEAN + has_cue_sheet : BOOLEAN + has_rip_log : BOOLEAN + download_link : TEXT + torrent_file : BYTEA -- - status : TEXT - progress : REAL + created_at : TIMESTAMPTZ + updated_at : TIMESTAMPTZ + } +} + +package "Download Management" #E8F5E9 { + entity "downloads" { + * id : UUID <> + -- + torrent_id : UUID <> + album_id : UUID + format : VARCHAR(20) + quality : VARCHAR(20) + -- + state : download_state + qbit_hash : VARCHAR(64) + save_path : TEXT error_message : TEXT -- - protocol : TEXT - indexer : TEXT - download_client : TEXT - torrent_hash : TEXT - output_path : TEXT - -- - added_at : TIMESTAMPTZ + queued_at : TIMESTAMPTZ + started_at : TIMESTAMPTZ completed_at : TIMESTAMPTZ + created_at : TIMESTAMPTZ + updated_at : TIMESTAMPTZ } - entity "blocklist" { + entity "download_files" { * id : UUID <> -- - artist_id : UUID <> - album_id : UUID <> + download_id : UUID <> + track_id : UUID <> -- - source_title : TEXT - quality : JSONB - size : BIGINT - protocol : TEXT - indexer : TEXT - message : TEXT - torrent_hash : TEXT + file_path : TEXT + file_size : BIGINT + file_type : VARCHAR(20) + sha256_hash : VARCHAR(64) -- - date : TIMESTAMPTZ + verified_at : TIMESTAMPTZ + created_at : TIMESTAMPTZ } } -' ══════════════════════════════════════════════════════════════ -' RELATIONSHIPS -' ══════════════════════════════════════════════════════════════ +package "Caching & Queue (River)" #F3E5F5 { + entity "river_job" { + * id : BIGSERIAL <> + -- + kind : TEXT + state : river_job_state + queue : TEXT + args : JSONB + metadata : JSONB + -- + attempt : SMALLINT + max_attempts : SMALLINT + priority : SMALLINT + -- + scheduled_at : TIMESTAMPTZ + attempted_at : TIMESTAMPTZ + created_at : TIMESTAMPTZ + finalized_at : TIMESTAMPTZ + } -' Core music relationships -artist_metadata ||--|| artists : "has config" -artist_metadata ||--o{ albums : "released" -albums ||--o{ album_releases : "has releases" -album_releases ||--o{ tracks : "contains" -tracks }o--o| track_files : "stored in" -track_files }o--|| albums : "belongs to" + entity "river_queue" { + * name : TEXT <> + -- + metadata : JSONB + paused_at : TIMESTAMPTZ + created_at : TIMESTAMPTZ + updated_at : TIMESTAMPTZ + } +} -' Artist config relationships -artists }o--|| quality_profiles : "uses" -artists }o--o| metadata_profiles : "uses" -artists }o--o| root_folders : "stored in" +note right of river_job + Cache refresh jobs: + kind = "indexer_cache_refresh" + args = {key, url, ttl_expires, refresh_interval} + scheduled_at = next refresh time +end note -' Root folder defaults -root_folders }o--o| quality_profiles : "default" -root_folders }o--o| metadata_profiles : "default" - -' Download tracking relationships -wanted_albums ||--|| albums : "targets" -download_queue }o--o| artists : "for" -download_queue }o--o| albums : "for" -blocklist }o--|| artists : "for" -blocklist }o--o| albums : "for" +artists ||--o{ albums : "released" +albums ||--o{ tracks : "contains" +albums ||--o{ torrents : "available on" +torrents ||--o| downloads : "downloaded as" +downloads ||--o{ download_files : "consists of" +tracks ||--o| download_files : "matched to" @enduml diff --git a/internal/database/album_repository.go b/internal/database/album_repository.go new file mode 100644 index 0000000..be11faf --- /dev/null +++ b/internal/database/album_repository.go @@ -0,0 +1,132 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Album struct { + ID string + ExternalID string + ArtistID string + Title string + AlbumType string + ReleaseDate *time.Time + TotalTracks int + TotalDiscs int + Label string + Genres []string + CoverURL string + IsMonitored bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type AlbumRepository struct { + pool *pgxpool.Pool +} + +func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository { + return &AlbumRepository{pool: pool} +} + +func (r *AlbumRepository) Create(ctx context.Context, a *Album) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO albums (external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (external_id) DO UPDATE SET + title = EXCLUDED.title, + album_type = EXCLUDED.album_type, + release_date = EXCLUDED.release_date, + total_tracks = EXCLUDED.total_tracks, + total_discs = EXCLUDED.total_discs, + label = EXCLUDED.label, + genres = EXCLUDED.genres, + cover_url = EXCLUDED.cover_url, + updated_at = NOW()`, + a.ExternalID, a.ArtistID, a.Title, a.AlbumType, a.ReleaseDate, a.TotalTracks, a.TotalDiscs, a.Label, a.Genres, a.CoverURL, a.IsMonitored, + ) + if err != nil { + return fmt.Errorf("creating album: %w", err) + } + return nil +} + +func (r *AlbumRepository) GetByExternalID(ctx context.Context, externalID string) (*Album, error) { + a := &Album{} + err := r.pool.QueryRow(ctx, + `SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at + FROM albums WHERE external_id = $1`, externalID, + ).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("getting album: %w", err) + } + return a, nil +} + +func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*Album, error) { + a := &Album{} + err := r.pool.QueryRow(ctx, + `SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at + FROM albums WHERE id = $1`, id, + ).Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("getting album: %w", err) + } + return a, nil +} + +func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string) ([]*Album, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at + FROM albums WHERE artist_id = $1 ORDER BY release_date DESC`, artistID, + ) + if err != nil { + return nil, fmt.Errorf("listing albums: %w", err) + } + defer rows.Close() + + var albums []*Album + for rows.Next() { + a := &Album{} + if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning album: %w", err) + } + albums = append(albums, a) + } + return albums, nil +} + +func (r *AlbumRepository) GetMonitored(ctx context.Context) ([]*Album, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, external_id, artist_id, title, album_type, release_date, total_tracks, total_discs, label, genres, cover_url, is_monitored, created_at, updated_at + FROM albums WHERE is_monitored = TRUE ORDER BY release_date DESC`, + ) + if err != nil { + return nil, fmt.Errorf("listing monitored albums: %w", err) + } + defer rows.Close() + + var albums []*Album + for rows.Next() { + a := &Album{} + if err := rows.Scan(&a.ID, &a.ExternalID, &a.ArtistID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.TotalTracks, &a.TotalDiscs, &a.Label, &a.Genres, &a.CoverURL, &a.IsMonitored, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning album: %w", err) + } + albums = append(albums, a) + } + return albums, nil +} + +func (r *AlbumRepository) SetMonitored(ctx context.Context, id string, monitored bool) error { + _, err := r.pool.Exec(ctx, + `UPDATE albums SET is_monitored = $1, updated_at = NOW() WHERE id = $2`, monitored, id, + ) + if err != nil { + return fmt.Errorf("updating monitored state: %w", err) + } + return nil +} diff --git a/internal/database/artist_repository.go b/internal/database/artist_repository.go new file mode 100644 index 0000000..15466d2 --- /dev/null +++ b/internal/database/artist_repository.go @@ -0,0 +1,73 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Artist struct { + ID string + ExternalID string + Name string + ArtistType string + Country string + Genres []string + ImageURL string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ArtistRepository struct { + pool *pgxpool.Pool +} + +func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository { + return &ArtistRepository{pool: pool} +} + +func (r *ArtistRepository) Create(ctx context.Context, a *Artist) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO artists (external_id, name, artist_type, country, genres, image_url) + VALUES ($1, $2, $3, $4, $5, $6) + 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, + updated_at = NOW() + RETURNING id, created_at, updated_at`, + a.ExternalID, a.Name, a.ArtistType, a.Country, a.Genres, a.ImageURL, + ) + if err != nil { + return fmt.Errorf("creating artist: %w", err) + } + return nil +} + +func (r *ArtistRepository) GetByExternalID(ctx context.Context, externalID string) (*Artist, error) { + a := &Artist{} + err := r.pool.QueryRow(ctx, + `SELECT id, external_id, name, artist_type, country, genres, image_url, 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) + if err != nil { + return nil, fmt.Errorf("getting artist: %w", err) + } + return a, 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 + 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) + if err != nil { + return nil, fmt.Errorf("getting artist: %w", err) + } + return a, nil +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..0151194 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,32 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func New(ctx context.Context, databaseURL string) (*DB, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connecting to database: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("pinging database: %w", err) + } + + log.Info().Str("url", databaseURL).Msg("database connected") + + return &DB{Pool: pool}, nil +} + +func (db *DB) Close() { + db.Pool.Close() +} diff --git a/internal/database/download_file_repository.go b/internal/database/download_file_repository.go new file mode 100644 index 0000000..b7f4ac8 --- /dev/null +++ b/internal/database/download_file_repository.go @@ -0,0 +1,104 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DownloadFile struct { + ID string + DownloadID string + TrackID *string + FilePath string + FileSize int64 + FileType string + SHA256Hash string + VerifiedAt *time.Time + CreatedAt time.Time +} + +type DownloadFileRepository struct { + pool *pgxpool.Pool +} + +func NewDownloadFileRepository(pool *pgxpool.Pool) *DownloadFileRepository { + return &DownloadFileRepository{pool: pool} +} + +func (r *DownloadFileRepository) Create(ctx context.Context, f *DownloadFile) error { + err := r.pool.QueryRow(ctx, + `INSERT INTO download_files (download_id, track_id, file_path, file_size, file_type, sha256_hash) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at`, + f.DownloadID, f.TrackID, f.FilePath, f.FileSize, f.FileType, f.SHA256Hash, + ).Scan(&f.ID, &f.CreatedAt) + if err != nil { + return fmt.Errorf("creating download file: %w", err) + } + return nil +} + +func (r *DownloadFileRepository) CreateBatch(ctx context.Context, files []*DownloadFile) error { + for _, f := range files { + if err := r.Create(ctx, f); err != nil { + return err + } + } + return nil +} + +func (r *DownloadFileRepository) GetByDownloadID(ctx context.Context, downloadID string) ([]*DownloadFile, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, download_id, track_id, file_path, file_size, file_type, sha256_hash, verified_at, created_at + FROM download_files WHERE download_id = $1 ORDER BY file_path`, downloadID, + ) + if err != nil { + return nil, fmt.Errorf("listing download files: %w", err) + } + defer rows.Close() + + var files []*DownloadFile + for rows.Next() { + f := &DownloadFile{} + if err := rows.Scan(&f.ID, &f.DownloadID, &f.TrackID, &f.FilePath, &f.FileSize, &f.FileType, &f.SHA256Hash, &f.VerifiedAt, &f.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning download file: %w", err) + } + files = append(files, f) + } + return files, nil +} + +func (r *DownloadFileRepository) SetHash(ctx context.Context, id string, hash string) error { + _, err := r.pool.Exec(ctx, + `UPDATE download_files SET sha256_hash = $1, verified_at = NOW() WHERE id = $2`, hash, id, + ) + if err != nil { + return fmt.Errorf("setting file hash: %w", err) + } + return nil +} + +func (r *DownloadFileRepository) GetUnverified(ctx context.Context) ([]*DownloadFile, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, download_id, track_id, file_path, file_size, file_type, sha256_hash, verified_at, created_at + FROM download_files WHERE sha256_hash IS NULL OR verified_at < NOW() - INTERVAL '30 days' + ORDER BY created_at`, + ) + if err != nil { + return nil, fmt.Errorf("listing unverified files: %w", err) + } + defer rows.Close() + + var files []*DownloadFile + for rows.Next() { + f := &DownloadFile{} + if err := rows.Scan(&f.ID, &f.DownloadID, &f.TrackID, &f.FilePath, &f.FileSize, &f.FileType, &f.SHA256Hash, &f.VerifiedAt, &f.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning download file: %w", err) + } + files = append(files, f) + } + return files, nil +} diff --git a/internal/database/download_repository.go b/internal/database/download_repository.go new file mode 100644 index 0000000..e51c6a7 --- /dev/null +++ b/internal/database/download_repository.go @@ -0,0 +1,122 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Download struct { + ID string + TorrentID string + AlbumID string + Format string + Quality string + State string + QbitHash string + SavePath string + ErrorMessage string + QueuedAt time.Time + StartedAt *time.Time + CompletedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type DownloadRepository struct { + pool *pgxpool.Pool +} + +func NewDownloadRepository(pool *pgxpool.Pool) *DownloadRepository { + return &DownloadRepository{pool: pool} +} + +func (r *DownloadRepository) Create(ctx context.Context, d *Download) error { + err := r.pool.QueryRow(ctx, + `INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash, save_path) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, queued_at, created_at, updated_at`, + d.TorrentID, d.AlbumID, d.Format, d.Quality, d.State, d.QbitHash, d.SavePath, + ).Scan(&d.ID, &d.QueuedAt, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return fmt.Errorf("creating download: %w", err) + } + return nil +} + +func (r *DownloadRepository) UpdateState(ctx context.Context, id string, state string) error { + _, err := r.pool.Exec(ctx, + `UPDATE downloads SET state = $1, updated_at = NOW() WHERE id = $2`, state, id, + ) + if err != nil { + return fmt.Errorf("updating download state: %w", err) + } + return nil +} + +func (r *DownloadRepository) SetStarted(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, + `UPDATE downloads SET state = 'downloading', started_at = NOW(), updated_at = NOW() WHERE id = $1`, id, + ) + if err != nil { + return fmt.Errorf("setting download started: %w", err) + } + return nil +} + +func (r *DownloadRepository) SetCompleted(ctx context.Context, id string, savePath string) error { + _, err := r.pool.Exec(ctx, + `UPDATE downloads SET state = 'completed', save_path = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2`, savePath, id, + ) + if err != nil { + return fmt.Errorf("setting download completed: %w", err) + } + return nil +} + +func (r *DownloadRepository) SetFailed(ctx context.Context, id string, errorMsg string) error { + _, err := r.pool.Exec(ctx, + `UPDATE downloads SET state = 'failed', error_message = $1, updated_at = NOW() WHERE id = $2`, errorMsg, id, + ) + if err != nil { + return fmt.Errorf("setting download failed: %w", err) + } + return nil +} + +func (r *DownloadRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Download, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at + FROM downloads WHERE album_id = $1 ORDER BY created_at DESC`, albumID, + ) + if err != nil { + return nil, fmt.Errorf("listing downloads: %w", err) + } + defer rows.Close() + + var downloads []*Download + for rows.Next() { + d := &Download{} + if err := rows.Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning download: %w", err) + } + downloads = append(downloads, d) + } + return downloads, nil +} + +func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, + `SELECT EXISTS( + SELECT 1 FROM downloads + WHERE album_id = $1 AND format = $2 AND quality = $3 AND state IN ('completed', 'seeding') + )`, albumID, format, quality, + ).Scan(&exists) + if err != nil { + return false, fmt.Errorf("checking album quality: %w", err) + } + return exists, nil +} diff --git a/internal/database/torrent_repository.go b/internal/database/torrent_repository.go new file mode 100644 index 0000000..cf56bcd --- /dev/null +++ b/internal/database/torrent_repository.go @@ -0,0 +1,104 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Torrent struct { + ID string + AlbumID string + InfoHash string + Tracker string + Title string + Format string + Quality string + Source string + BitDepth int + SampleRate int + Seeders int + Peers int + Size int64 + TrackCount int + HasCoverArt bool + HasCueSheet bool + HasRipLog bool + DownloadLink string + TorrentFile []byte + CreatedAt time.Time + UpdatedAt time.Time +} + +type TorrentRepository struct { + pool *pgxpool.Pool +} + +func NewTorrentRepository(pool *pgxpool.Pool) *TorrentRepository { + return &TorrentRepository{pool: pool} +} + +func (r *TorrentRepository) Create(ctx context.Context, t *Torrent) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + ON CONFLICT (info_hash) DO UPDATE SET + seeders = EXCLUDED.seeders, + peers = EXCLUDED.peers, + updated_at = NOW()`, + t.AlbumID, t.InfoHash, t.Tracker, t.Title, t.Format, t.Quality, t.Source, t.BitDepth, t.SampleRate, t.Seeders, t.Peers, t.Size, t.TrackCount, t.HasCoverArt, t.HasCueSheet, t.HasRipLog, t.DownloadLink, t.TorrentFile, + ) + if err != nil { + return fmt.Errorf("creating torrent: %w", err) + } + return nil +} + +func (r *TorrentRepository) GetByInfoHash(ctx context.Context, infoHash string) (*Torrent, error) { + t := &Torrent{} + err := r.pool.QueryRow(ctx, + `SELECT id, album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file, created_at, updated_at + FROM torrents WHERE info_hash = $1`, infoHash, + ).Scan(&t.ID, &t.AlbumID, &t.InfoHash, &t.Tracker, &t.Title, &t.Format, &t.Quality, &t.Source, &t.BitDepth, &t.SampleRate, &t.Seeders, &t.Peers, &t.Size, &t.TrackCount, &t.HasCoverArt, &t.HasCueSheet, &t.HasRipLog, &t.DownloadLink, &t.TorrentFile, &t.CreatedAt, &t.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("getting torrent: %w", err) + } + return t, nil +} + +func (r *TorrentRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Torrent, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, album_id, info_hash, tracker, title, format, quality, source, bit_depth, sample_rate, seeders, peers, size, track_count, has_cover_art, has_cue_sheet, has_rip_log, download_link, torrent_file, created_at, updated_at + FROM torrents WHERE album_id = $1 ORDER BY seeders DESC`, albumID, + ) + if err != nil { + return nil, fmt.Errorf("listing torrents: %w", err) + } + defer rows.Close() + + var torrents []*Torrent + for rows.Next() { + t := &Torrent{} + if err := rows.Scan(&t.ID, &t.AlbumID, &t.InfoHash, &t.Tracker, &t.Title, &t.Format, &t.Quality, &t.Source, &t.BitDepth, &t.SampleRate, &t.Seeders, &t.Peers, &t.Size, &t.TrackCount, &t.HasCoverArt, &t.HasCueSheet, &t.HasRipLog, &t.DownloadLink, &t.TorrentFile, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning torrent: %w", err) + } + torrents = append(torrents, t) + } + return torrents, nil +} + +func (r *TorrentRepository) HasAlbumInFormat(ctx context.Context, albumID string, format string) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, + `SELECT EXISTS( + SELECT 1 FROM downloads + WHERE album_id = $1 AND format = $2 AND state IN ('completed', 'seeding') + )`, albumID, format, + ).Scan(&exists) + if err != nil { + return false, fmt.Errorf("checking album format: %w", err) + } + return exists, nil +} diff --git a/internal/database/track_repository.go b/internal/database/track_repository.go new file mode 100644 index 0000000..6956b0c --- /dev/null +++ b/internal/database/track_repository.go @@ -0,0 +1,68 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Track struct { + ID string + ExternalID string + AlbumID string + Title string + DurationMS int + ISRC string + DiscNumber int + TrackNumber int + CreatedAt time.Time +} + +type TrackRepository struct { + pool *pgxpool.Pool +} + +func NewTrackRepository(pool *pgxpool.Pool) *TrackRepository { + return &TrackRepository{pool: pool} +} + +func (r *TrackRepository) Create(ctx context.Context, t *Track) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO tracks (external_id, album_id, title, duration_ms, isrc, disc_number, track_number) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (external_id) DO UPDATE SET + title = EXCLUDED.title, + duration_ms = EXCLUDED.duration_ms, + isrc = EXCLUDED.isrc, + disc_number = EXCLUDED.disc_number, + track_number = EXCLUDED.track_number`, + t.ExternalID, t.AlbumID, t.Title, t.DurationMS, t.ISRC, t.DiscNumber, t.TrackNumber, + ) + if err != nil { + return fmt.Errorf("creating track: %w", err) + } + return nil +} + +func (r *TrackRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*Track, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, external_id, album_id, title, duration_ms, isrc, disc_number, track_number, created_at + FROM tracks WHERE album_id = $1 ORDER BY disc_number, track_number`, albumID, + ) + if err != nil { + return nil, fmt.Errorf("listing tracks: %w", err) + } + defer rows.Close() + + var tracks []*Track + for rows.Next() { + t := &Track{} + if err := rows.Scan(&t.ID, &t.ExternalID, &t.AlbumID, &t.Title, &t.DurationMS, &t.ISRC, &t.DiscNumber, &t.TrackNumber, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning track: %w", err) + } + tracks = append(tracks, t) + } + return tracks, nil +}