// 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} }