Files
owenandClaude Sonnet 5 e48f063f03 Add structured lyrics storage and indexing (line/word timing, byte offsets)
Adds database.InsertLyrics/GetSongStructuredLyrics storing multiple
lyrics entries per song with per-line start times and, when the
source provides it, word-level cues carrying precomputed UTF-8 byte
offsets. library.IndexLyrics wires this into the scanner: embedded
plain-text tags are stored unsynced, and a same-basename sidecar .lrc
(plain or Enhanced) is parsed and stored synced, with word cues when
present. IndexFile now takes the media file's absolute path so lyrics
indexing can locate sidecar files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:16:42 -05:00

176 lines
5.3 KiB
Go

// Package library turns parsed file metadata into the relational tag
// model (artists, albums, songs, genres, credits) that both filesystem-
// style and ID3-style Subsonic browsing will read from in later phases.
package library
import (
"database/sql"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
)
// IndexFile creates or updates the songs row (and its album/artist/genre
// relations) for a scanned media file, given its parsed tags. It is
// idempotent: rerunning it for the same file converges to the same rows,
// reusing existing artist/album/genre IDs via their natural keys so IDs
// stay stable across rescans.
func IndexFile(db *sql.DB, mediaFile *database.MediaFile, absPath string, tags *metadata.Tags) (*database.Song, error) {
title := tags.Title
if title == "" {
title = mediaFile.RelativePath
}
primaryArtistID, err := resolvePrimaryAlbumArtist(db, tags)
if err != nil {
return nil, err
}
var albumID sql.NullString
if tags.Album != "" {
album, err := database.FindOrCreateAlbum(db, tags.Album, primaryArtistID, tags.SortAlbum,
intToNull(tags.Year), intToNull(tags.OriginalYear), tags.RecordLabel, tags.MusicBrainzAlbumID)
if err != nil {
return nil, err
}
albumID = sql.NullString{String: album.ID, Valid: true}
if err := linkAlbumArtists(db, album.ID, tags, primaryArtistID); err != nil {
return nil, err
}
for _, name := range tags.Genres {
genre, err := database.FindOrCreateGenre(db, name)
if err != nil {
return nil, err
}
if err := database.LinkAlbumGenre(db, album.ID, genre.ID); err != nil {
return nil, err
}
}
}
song, err := database.UpsertSong(db, database.UpsertSongInput{
MediaFileID: mediaFile.ID,
AlbumID: albumID,
Title: title,
SortTitle: tags.SortTitle,
TrackNumber: intToNull(tags.TrackNumber),
TrackTotal: intToNull(tags.TrackTotal),
DiscNumber: intToNull(tags.DiscNumber),
DiscTotal: intToNull(tags.DiscTotal),
DiscSubtitle: tags.DiscSubtitle,
Composer: tags.Composer,
Conductor: tags.Conductor,
Comment: tags.Comment,
ISRC: tags.ISRC,
BPM: intToNull(tags.BPM),
MusicBrainzTrackID: tags.MusicBrainzTrackID,
MusicBrainzRecordingID: tags.MusicBrainzRecordingID,
ReplayGainTrackGainDB: floatPtrToNull(tags.ReplayGainTrackGainDB),
ReplayGainTrackPeak: floatPtrToNull(tags.ReplayGainTrackPeak),
ReplayGainAlbumGainDB: floatPtrToNull(tags.ReplayGainAlbumGainDB),
ReplayGainAlbumPeak: floatPtrToNull(tags.ReplayGainAlbumPeak),
})
if err != nil {
return nil, err
}
if err := database.ClearSongArtists(db, song.ID); err != nil {
return nil, err
}
for i, name := range tags.Artists {
artist, err := database.FindOrCreateArtist(db, name, "", "")
if err != nil {
return nil, err
}
if err := database.LinkSongArtist(db, song.ID, artist.ID, "artist", i); err != nil {
return nil, err
}
}
if err := database.ClearContributors(db, song.ID); err != nil {
return nil, err
}
for i, c := range tags.Contributors {
artist, err := database.FindOrCreateArtist(db, c.Name, "", "")
if err != nil {
return nil, err
}
if err := database.LinkContributor(db, song.ID, artist.ID, c.Role, i); err != nil {
return nil, err
}
}
if err := database.ClearSongGenres(db, song.ID); err != nil {
return nil, err
}
for _, name := range tags.Genres {
genre, err := database.FindOrCreateGenre(db, name)
if err != nil {
return nil, err
}
if err := database.LinkSongGenre(db, song.ID, genre.ID); err != nil {
return nil, err
}
}
if err := IndexLyrics(db, song.ID, absPath, tags.EmbeddedLyrics); err != nil {
return nil, err
}
return song, nil
}
// resolvePrimaryAlbumArtist picks the artist an album groups under:
// the explicit album artist tag if present, otherwise the first
// performer credited on the track.
func resolvePrimaryAlbumArtist(db *sql.DB, tags *metadata.Tags) (sql.NullString, error) {
name := tags.AlbumArtist
sortName := tags.SortAlbumArtist
if name == "" && len(tags.Artists) > 0 {
name = tags.Artists[0]
}
if name == "" {
return sql.NullString{}, nil
}
artist, err := database.FindOrCreateArtist(db, name, sortName, "")
if err != nil {
return sql.NullString{}, err
}
return sql.NullString{String: artist.ID, Valid: true}, nil
}
func linkAlbumArtists(db *sql.DB, albumID string, tags *metadata.Tags, primaryArtistID sql.NullString) error {
if tags.AlbumArtist != "" {
if primaryArtistID.Valid {
return database.LinkAlbumArtist(db, albumID, primaryArtistID.String, 0)
}
return nil
}
// No explicit album artist: treat every track performer as an album artist.
for i, name := range tags.Artists {
artist, err := database.FindOrCreateArtist(db, name, "", "")
if err != nil {
return err
}
if err := database.LinkAlbumArtist(db, albumID, artist.ID, i); err != nil {
return err
}
}
return nil
}
func intToNull(n int) sql.NullInt64 {
if n == 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(n), Valid: true}
}
func floatPtrToNull(f *float64) sql.NullFloat64 {
if f == nil {
return sql.NullFloat64{}
}
return sql.NullFloat64{Float64: *f, Valid: true}
}