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
176 lines
5.3 KiB
Go
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}
|
|
}
|