diff --git a/backend/database/sql/queries/artist_credit.sql b/backend/database/sql/queries/artist_credit.sql index b659668..e80020a 100644 --- a/backend/database/sql/queries/artist_credit.sql +++ b/backend/database/sql/queries/artist_credit.sql @@ -26,3 +26,9 @@ WHERE id = ?; -- name: DeleteAllArtistCredits :exec DELETE FROM artist_credit; + +-- name: CountArtistCreditReferences :one +SELECT + (SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) + + (SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1) +AS total; diff --git a/backend/database/sql/queries/genres.sql b/backend/database/sql/queries/genres.sql index 301e8f5..f2a87e2 100644 --- a/backend/database/sql/queries/genres.sql +++ b/backend/database/sql/queries/genres.sql @@ -103,6 +103,12 @@ LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE g.name = ? AND af.library_id = ? ORDER BY r.name; +-- name: CountGenreReferences :one +SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?; + +-- name: DeleteGenre :exec +DELETE FROM genres WHERE id = ?; + -- name: GetAllGenresWithCounts :many SELECT g.name, COUNT(rg.recording_id) AS track_count FROM genres g diff --git a/backend/database/sql/queries/recordings.sql b/backend/database/sql/queries/recordings.sql index 31bab1a..ca03f26 100644 --- a/backend/database/sql/queries/recordings.sql +++ b/backend/database/sql/queries/recordings.sql @@ -34,3 +34,6 @@ DELETE FROM recordings; -- name: GetAllRecordings :many SELECT * FROM recordings ORDER BY name; + +-- name: CountRecordingsByArtistCredit :one +SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?; diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql index 9435a87..4c0c059 100644 --- a/backend/database/sql/queries/release_groups.sql +++ b/backend/database/sql/queries/release_groups.sql @@ -111,6 +111,9 @@ LEFT JOIN ( WHERE aca.artist_id = ? ORDER BY rg.name; +-- name: CountReleaseGroupRecordings :one +SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?; + -- name: GetAlbumsByArtistByLibrary :many SELECT rg.id, diff --git a/backend/database/sql/sqlcgen/artist_credit.sql.go b/backend/database/sql/sqlcgen/artist_credit.sql.go index 5d17bc0..6c69c8c 100644 --- a/backend/database/sql/sqlcgen/artist_credit.sql.go +++ b/backend/database/sql/sqlcgen/artist_credit.sql.go @@ -9,6 +9,20 @@ import ( "context" ) +const countArtistCreditReferences = `-- name: CountArtistCreditReferences :one +SELECT + (SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) + + (SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1) +AS total +` + +func (q *Queries) CountArtistCreditReferences(ctx context.Context, artistCreditID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countArtistCreditReferences, artistCreditID) + var total int64 + err := row.Scan(&total) + return total, err +} + const createArtistCredit = `-- name: CreateArtistCredit :one INSERT INTO artist_credit (text) VALUES (?) RETURNING id, text diff --git a/backend/database/sql/sqlcgen/genres.sql.go b/backend/database/sql/sqlcgen/genres.sql.go index b77e58f..e577a79 100644 --- a/backend/database/sql/sqlcgen/genres.sql.go +++ b/backend/database/sql/sqlcgen/genres.sql.go @@ -10,6 +10,17 @@ import ( "database/sql" ) +const countGenreReferences = `-- name: CountGenreReferences :one +SELECT COUNT(*) FROM recording_genres WHERE genre_id = ? +` + +func (q *Queries) CountGenreReferences(ctx context.Context, genreID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countGenreReferences, genreID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createRecordingGenre = `-- name: CreateRecordingGenre :exec INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) VALUES (?, ?) @@ -43,6 +54,15 @@ func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error { return err } +const deleteGenre = `-- name: DeleteGenre :exec +DELETE FROM genres WHERE id = ? +` + +func (q *Queries) DeleteGenre(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteGenre, id) + return err +} + const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec DELETE FROM recording_genres WHERE recording_id = ? diff --git a/backend/database/sql/sqlcgen/recordings.sql.go b/backend/database/sql/sqlcgen/recordings.sql.go index 4519a8b..2d9cedf 100644 --- a/backend/database/sql/sqlcgen/recordings.sql.go +++ b/backend/database/sql/sqlcgen/recordings.sql.go @@ -10,6 +10,17 @@ import ( "database/sql" ) +const countRecordingsByArtistCredit = `-- name: CountRecordingsByArtistCredit :one +SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ? +` + +func (q *Queries) CountRecordingsByArtistCredit(ctx context.Context, artistCreditID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countRecordingsByArtistCredit, artistCreditID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createRecording = `-- name: CreateRecording :one INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?) RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go index cc99f25..df1c187 100644 --- a/backend/database/sql/sqlcgen/release_groups.sql.go +++ b/backend/database/sql/sqlcgen/release_groups.sql.go @@ -10,6 +10,17 @@ import ( "database/sql" ) +const countReleaseGroupRecordings = `-- name: CountReleaseGroupRecordings :one +SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ? +` + +func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countReleaseGroupRecordings, releaseGroupID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createReleaseGroup = `-- name: CreateReleaseGroup :one INSERT INTO release_groups (name) VALUES (?) RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs diff --git a/backend/tagwriter/flac.go b/backend/tagwriter/flac.go new file mode 100644 index 0000000..04330ba --- /dev/null +++ b/backend/tagwriter/flac.go @@ -0,0 +1,185 @@ +package tagwriter + +import ( + "fmt" + "log/slog" + "os" + "strconv" + "strings" + + "github.com/go-flac/flacpicture/v2" + "github.com/go-flac/flacvorbis/v2" + flac "github.com/go-flac/go-flac/v2" + + "yellowjacket/backend/fileutil" +) + +// writeFlacTags writes metadata tags to a FLAC file using Vorbis Comments +// and PICTURE metadata blocks, integrated with AtomicWrite for crash safety. +func writeFlacTags(logger *slog.Logger, filePath string, changes TagChanges) error { + // Check file size and warn for very large files. + if info, err := os.Stat(filePath); err == nil { + const largeSizeThreshold = 500 * 1024 * 1024 // 500 MB + if info.Size() > largeSizeThreshold { + logger.Warn("large FLAC file may use significant memory", + slog.String("path", filePath), + slog.Int64("size", info.Size()), + ) + } + } + + f, err := flac.ParseFile(filePath) + if err != nil { + return fmt.Errorf("parse flac: %w", err) + } + + // Find existing Vorbis Comment block. + var cmt *flacvorbis.MetaDataBlockVorbisComment + + cmtIdx := -1 + + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + return fmt.Errorf("parse vorbis comments: %w", err) + } + + cmtIdx = idx + + break + } + } + + if cmt == nil { + cmt = flacvorbis.New() + } + + // Apply text changes from the diff map. + if err := applyFlacTextChanges(cmt, changes); err != nil { + return fmt.Errorf("apply flac text changes: %w", err) + } + + // Marshal Vorbis Comment block back and update f.Meta. + cmtMeta := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtMeta + } else { + f.Meta = append(f.Meta, &cmtMeta) + } + + // Handle cover art — PICTURE metadata block. + if err := applyFlacCoverArt(f, changes); err != nil { + return fmt.Errorf("apply flac cover art: %w", err) + } + + // Write atomically via AtomicWrite. + return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error { + _, writeErr := f.WriteTo(tmp) + if writeErr != nil { + return fmt.Errorf("write flac: %w", writeErr) + } + + return nil + }) +} + +// applyFlacTextChanges applies text field changes to a Vorbis Comment block. +func applyFlacTextChanges(cmt *flacvorbis.MetaDataBlockVorbisComment, changes TagChanges) error { + type fieldMapping struct { + key string + vorbisID string + isInt bool + } + + mappings := []fieldMapping{ + {FieldTitle, flacvorbis.FIELD_TITLE, false}, + {FieldArtist, flacvorbis.FIELD_ARTIST, false}, + {FieldAlbum, flacvorbis.FIELD_ALBUM, false}, + {FieldAlbumArtist, "ALBUMARTIST", false}, + {FieldGenre, flacvorbis.FIELD_GENRE, false}, + {FieldYear, flacvorbis.FIELD_DATE, true}, + {FieldTrackNumber, flacvorbis.FIELD_TRACKNUMBER, true}, + {FieldDiscNumber, "DISCNUMBER", true}, + {FieldComposer, "COMPOSER", false}, + } + + for _, m := range mappings { + v, ok := changes[m.key] + if !ok { + continue + } + + var val string + if m.isInt { + val = strconv.Itoa(v.(int)) + } else { + val = v.(string) + } + + replaceVorbisComment(cmt, m.vorbisID, val) + } + + return nil +} + +// replaceVorbisComment removes all existing entries for a field and adds a +// new value. Vorbis Comment field names are case-insensitive per spec. +func replaceVorbisComment(cmt *flacvorbis.MetaDataBlockVorbisComment, field string, value string) { + // Remove all existing entries for this field (case-insensitive). + prefix := strings.ToUpper(field) + "=" + filtered := make([]string, 0, len(cmt.Comments)) + + for _, c := range cmt.Comments { + if !strings.HasPrefix(strings.ToUpper(c), prefix) { + filtered = append(filtered, c) + } + } + + cmt.Comments = filtered + + // Add the new value. Using the uppercase field name (Vorbis convention). + _ = cmt.Add(strings.ToUpper(field), value) +} + +// applyFlacCoverArt handles adding, replacing, or clearing PICTURE metadata +// blocks in a FLAC file. +func applyFlacCoverArt(f *flac.File, changes TagChanges) error { + v, ok := changes[FieldCoverArt] + if !ok { + return nil + } + + // Remove all existing PICTURE blocks. + newMeta := make([]*flac.MetaDataBlock, 0, len(f.Meta)) + + for _, meta := range f.Meta { + if meta.Type != flac.Picture { + newMeta = append(newMeta, meta) + } + } + + f.Meta = newMeta + + // If value is nil or empty, we've cleared the art — done. + data, isBytes := v.([]byte) + if !isBytes || len(data) == 0 { + return nil + } + + // Create new PICTURE block with the provided image data. + pic, err := flacpicture.NewFromImageData( + flacpicture.PictureTypeFrontCover, + "Front cover", + data, + detectMIME(data), + ) + if err != nil { + return fmt.Errorf("create flac picture: %w", err) + } + + picMeta := pic.Marshal() + f.Meta = append(f.Meta, &picMeta) + + return nil +} diff --git a/backend/tagwriter/tagwriter.go b/backend/tagwriter/tagwriter.go new file mode 100644 index 0000000..a266419 --- /dev/null +++ b/backend/tagwriter/tagwriter.go @@ -0,0 +1,107 @@ +// Package tagwriter writes metadata tags to audio files. +package tagwriter + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// TagChanges is a diff map of field name → new value. Only changed +// fields are present. Callers specify changed fields; unchanged +// fields are left as-is in the file. +type TagChanges map[string]any + +// Field name constants for the diff map. +const ( + FieldTitle = "title" + FieldArtist = "artist" + FieldAlbum = "album" + FieldAlbumArtist = "album_artist" + FieldGenre = "genre" + FieldYear = "year" + FieldTrackNumber = "track_number" + FieldDiscNumber = "disc_number" + FieldComposer = "composer" + FieldCoverArt = "cover_art" // []byte for set, nil for clear +) + +// AudioFormat represents a supported audio file format. +type AudioFormat string + +const ( + // FormatMP3 is the MP3 audio format. + FormatMP3 AudioFormat = "mp3" + // FormatFLAC is the FLAC audio format. + FormatFLAC AudioFormat = "flac" +) + +// errUnsupportedFormat is returned when the audio format is not supported. +var errUnsupportedFormat = errors.New("tagwriter: unsupported audio format") + +// DetectFormat determines the audio format of a file from its extension. +func DetectFormat(filePath string) (AudioFormat, error) { + ext := strings.ToLower(filepath.Ext(filePath)) + + switch ext { + case ".mp3": + return FormatMP3, nil + case ".flac": + return FormatFLAC, nil + default: + return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext) + } +} + +// detectMIME returns the MIME type of image data by checking magic bytes. +func detectMIME(data []byte) string { + if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xD8 { + return "image/jpeg" + } + + if len(data) >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return "image/png" + } + + return "application/octet-stream" +} + +// id3v2OriginalTagSize reads an MP3 file's ID3v2 header and returns the +// total number of bytes occupied by the tag (10-byte header + body). +// If the file does not start with an ID3v2 header it returns 0. +func id3v2OriginalTagSize(path string) (int64, error) { + f, err := os.Open(path) + if err != nil { + return 0, fmt.Errorf("open for tag size: %w", err) + } + + defer func() { _ = f.Close() }() + + // ID3v2 header: "ID3" (3 B) + version (2 B) + flags (1 B) + size (4 B synchsafe). + var hdr [10]byte + if _, err := f.Read(hdr[:]); err != nil { + return 0, fmt.Errorf("read id3v2 header: %w", err) + } + + if string(hdr[:3]) != "ID3" { + return 0, nil + } + + size := decodeSynchSafe(hdr[6:10]) + + const headerBytes = 10 + + return headerBytes + int64(size), nil +} + +// decodeSynchSafe decodes a 4-byte synchsafe integer (7 bits per byte). +func decodeSynchSafe(b []byte) uint32 { + _ = b[3] // bounds check hint + + return uint32(b[0])<<21 | + uint32(b[1])<<14 | + uint32(b[2])<<7 | + uint32(b[3]) +} diff --git a/go.mod b/go.mod index 269693f..91979ed 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/bep/debounce v1.2.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bogem/id3v2/v2 v2.1.4 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect github.com/bombsimon/wsl/v5 v5.6.0 // indirect github.com/breml/bidichk v0.3.3 // indirect @@ -112,6 +113,9 @@ require ( github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/ghostiam/protogetter v0.3.20 // indirect github.com/go-critic/go-critic v0.14.3 // indirect + github.com/go-flac/flacpicture/v2 v2.0.2 // indirect + github.com/go-flac/flacvorbis/v2 v2.0.2 // indirect + github.com/go-flac/go-flac/v2 v2.0.4 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.13.2 // indirect diff --git a/go.sum b/go.sum index d545650..ff58046 100644 --- a/go.sum +++ b/go.sum @@ -164,6 +164,8 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5 github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= +github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8= @@ -288,6 +290,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= +github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= +github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= +github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= +github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= +github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0= +github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= @@ -1207,6 +1215,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=