Library indexing and filesystem scanner with incremental rescans

This commit is contained in:
2026-08-22 23:31:17 -05:00
parent d4ea7a4ca9
commit 49dbee617a
5 changed files with 915 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
package library
import (
"database/sql"
"strings"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// DirEntry is one immediate child of a directory in filesystem-style
// browsing (Subsonic's getIndexes/getMusicDirectory): either a
// subdirectory (Dir=true) or a media file.
type DirEntry struct {
Name string
Dir bool
File *database.MediaFile // set when Dir is false
}
// ListDirectory returns the immediate children of subPath within a music
// folder ("" for the folder root), built from media_files.relative_path.
// This is the foundation for filesystem-style browsing, kept separate
// from the ID3/tag model in library/index.go so neither is faked in
// terms of the other.
func ListDirectory(db *sql.DB, folderID, subPath string) ([]DirEntry, error) {
files, err := database.ListMediaFilesForFolder(db, folderID)
if err != nil {
return nil, err
}
prefix := strings.Trim(subPath, "/")
seen := make(map[string]bool)
var out []DirEntry
for _, f := range files {
if f.Missing {
continue
}
rel := f.RelativePath
if prefix != "" {
if !strings.HasPrefix(rel, prefix+"/") {
continue
}
rel = rel[len(prefix)+1:]
}
parts := strings.SplitN(rel, "/", 2)
name := parts[0]
if name == "" || seen[name] {
continue
}
if len(parts) == 2 {
seen[name] = true
out = append(out, DirEntry{Name: name, Dir: true})
continue
}
seen[name] = true
out = append(out, DirEntry{Name: name, Dir: false, File: f})
}
return out, nil
}
+171
View File
@@ -0,0 +1,171 @@
// 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, 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
}
}
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}
}
+171
View File
@@ -0,0 +1,171 @@
package library
import (
"database/sql"
"path/filepath"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("database.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func makeMediaFile(t *testing.T, db *sql.DB, relPath string) *database.MediaFile {
t.Helper()
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
if err != nil {
t.Fatalf("CreateMusicFolder: %v", err)
}
mf, err := database.CreateMediaFile(db, folder.ID, relPath, 1000, 0, "hash1", "flac", "song", 180, 900, 44100, 2)
if err != nil {
t.Fatalf("CreateMediaFile: %v", err)
}
return mf
}
func TestIndexFileCreatesArtistAlbumSong(t *testing.T) {
db := openTestDB(t)
mf := makeMediaFile(t, db, "Artist/Album/01 - Song.flac")
tags := &metadata.Tags{
Title: "Song One",
Album: "Great Album",
AlbumArtist: "Great Artist",
Artists: []string{"Great Artist", "Featured Person"},
Genres: []string{"Rock", "Indie"},
TrackNumber: 1,
Year: 2020,
}
song, err := IndexFile(db, mf, tags)
if err != nil {
t.Fatalf("IndexFile: %v", err)
}
if song.Title != "Song One" {
t.Errorf("Title = %q", song.Title)
}
if !song.AlbumID.Valid {
t.Fatal("expected song to have an album")
}
album, err := database.GetAlbum(db, song.AlbumID.String)
if err != nil {
t.Fatalf("GetAlbum: %v", err)
}
if album.Title != "Great Album" {
t.Errorf("Album title = %q", album.Title)
}
names, err := database.ListSongArtistNames(db, song.ID)
if err != nil {
t.Fatalf("ListSongArtistNames: %v", err)
}
if len(names) != 2 || names[0] != "Great Artist" || names[1] != "Featured Person" {
t.Errorf("song artists = %v", names)
}
genres, err := database.ListGenres(db)
if err != nil {
t.Fatalf("ListGenres: %v", err)
}
if len(genres) != 2 {
t.Errorf("genres = %v, want 2", genres)
}
}
func TestIndexFileIsIdempotent(t *testing.T) {
db := openTestDB(t)
mf := makeMediaFile(t, db, "Artist/Album/01 - Song.flac")
tags := &metadata.Tags{Title: "Song", Album: "Album", AlbumArtist: "Artist", Artists: []string{"Artist"}}
song1, err := IndexFile(db, mf, tags)
if err != nil {
t.Fatalf("IndexFile (1st): %v", err)
}
song2, err := IndexFile(db, mf, tags)
if err != nil {
t.Fatalf("IndexFile (2nd): %v", err)
}
if song1.ID != song2.ID {
t.Errorf("song ID changed across reindex: %s -> %s", song1.ID, song2.ID)
}
if song1.AlbumID.String != song2.AlbumID.String {
t.Errorf("album ID changed across reindex: %s -> %s", song1.AlbumID.String, song2.AlbumID.String)
}
artists, err := database.ListSongArtistNames(db, song1.ID)
if err != nil {
t.Fatalf("ListSongArtistNames: %v", err)
}
if len(artists) != 1 {
t.Errorf("reindexing duplicated song_artists rows: %v", artists)
}
}
func TestIndexFileUsesFilenameWhenNoTitle(t *testing.T) {
db := openTestDB(t)
mf := makeMediaFile(t, db, "Unknown/Track.flac")
song, err := IndexFile(db, mf, &metadata.Tags{})
if err != nil {
t.Fatalf("IndexFile: %v", err)
}
if song.Title != mf.RelativePath {
t.Errorf("Title = %q, want fallback to relative path %q", song.Title, mf.RelativePath)
}
}
func TestListDirectory(t *testing.T) {
db := openTestDB(t)
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
if err != nil {
t.Fatalf("CreateMusicFolder: %v", err)
}
for _, p := range []string{"ArtistA/Album1/01.flac", "ArtistA/Album1/02.flac", "ArtistA/Album2/01.flac", "ArtistB/Album3/01.flac"} {
if _, err := database.CreateMediaFile(db, folder.ID, p, 1, 0, "h"+p, "flac", "song", 1, 1, 1, 1); err != nil {
t.Fatalf("CreateMediaFile(%s): %v", p, err)
}
}
root, err := ListDirectory(db, folder.ID, "")
if err != nil {
t.Fatalf("ListDirectory(root): %v", err)
}
if len(root) != 2 {
t.Fatalf("root entries = %v, want 2 (ArtistA, ArtistB)", root)
}
for _, e := range root {
if !e.Dir {
t.Errorf("root entry %q should be a directory", e.Name)
}
}
sub, err := ListDirectory(db, folder.ID, "ArtistA")
if err != nil {
t.Fatalf("ListDirectory(ArtistA): %v", err)
}
if len(sub) != 2 {
t.Fatalf("ArtistA entries = %v, want 2 (Album1, Album2)", sub)
}
files, err := ListDirectory(db, folder.ID, "ArtistA/Album1")
if err != nil {
t.Fatalf("ListDirectory(ArtistA/Album1): %v", err)
}
if len(files) != 2 {
t.Fatalf("Album1 entries = %v, want 2 files", files)
}
for _, e := range files {
if e.Dir {
t.Errorf("entry %q should be a file", e.Name)
}
}
}