From 5f7de5060a5bc557b96203267de694ef366ed507 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 16 Mar 2026 09:29:08 -0400 Subject: [PATCH] feat(13-01): add library-filtered Go query methods and FTS search - GetAllTracksByLibrary, GetAllAlbumsByLibrary, GetAllArtistsByLibrary - GetAlbumsByArtistByLibrary, GetAllGenresWithCountsByLibrary - GetTracksByGenreByLibrary, GetAlbumTracksByLibrary - SearchTracksByLibrary wraps SearchFTSTracksByLibrary on DB - SearchFTSTracksByLibrary filters FTS results by library_id --- backend/database/search.go | 87 +++++++++ backend/library/query.go | 382 +++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) diff --git a/backend/database/search.go b/backend/database/search.go index a0fba59..406a2cc 100644 --- a/backend/database/search.go +++ b/backend/database/search.go @@ -287,6 +287,93 @@ func (d *DB) SearchFTSTracks( return results, nil } +// SearchFTSTracksByLibrary performs a full-text search scoped to a +// specific library and returns full track metadata for each match. +func (d *DB) SearchFTSTracksByLibrary( + query string, limit int, libraryID int64, +) ([]SearchTrackRow, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + ftsQuery := buildFTSQuery(query) + + // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. + rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + tm.file_path, + tm.length_milliseconds, + tm.title, + tm.artist_name, + tm.track_number, + tm.disc_number, + tm.album, + tm.genre, + tm.year, + tm.composer, + tm.file_type, + tm.sample_rate, + tm.bit_depth, + tm.channels, + tm.bitrate, + tm.file_size + FROM search_index si + JOIN track_metadata tm ON tm.id = si.rowid + WHERE search_index MATCH ? AND tm.library_id = ? + ORDER BY rank + LIMIT ? + `, ftsQuery, libraryID, limit) + if err != nil { + return nil, fmt.Errorf( + "FTS library track search failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var results []SearchTrackRow + + for rows.Next() { + var r SearchTrackRow + + if err := rows.Scan( + &r.FilePath, + &r.LengthMilliseconds, + &r.Title, + &r.ArtistName, + &r.TrackNumber, + &r.DiscNumber, + &r.Album, + &r.Genre, + &r.Year, + &r.Composer, + &r.FileType, + &r.SampleRate, + &r.BitDepth, + &r.Channels, + &r.Bitrate, + &r.FileSize, + ); err != nil { + return nil, fmt.Errorf( + "could not scan library search track row: %w", + err, + ) + } + + results = append(results, r) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "library search track row iteration error: %w", + err, + ) + } + + return results, nil +} + // scanSearchRows reads all rows from a query result into a slice. func scanSearchRows( rows interface { diff --git a/backend/library/query.go b/backend/library/query.go index 3def8b8..cd4044a 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -8,6 +8,7 @@ import ( "strings" "yellowjacket/backend/coverart" + "yellowjacket/backend/database/sql/sqlcgen" ) // Sentinel errors for library queries. @@ -444,6 +445,387 @@ func (l *Library) GetAllGenresWithCounts() ( return genres, nil } +// GetAllTracksByLibrary returns tracks scoped to a specific library. +func (l *Library) GetAllTracksByLibrary( + libraryID int64, +) ([]Track, error) { + rows, err := l.db.Queries.GetAllTracksWithFullMetadataByLibrary( + l.ctx, libraryID, + ) + if err != nil { + l.logger.Error( + "could not retrieve tracks for library", + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get tracks for library: %w", err, + ) + } + + l.logger.Info( + "tracks for library", + "libraryID", libraryID, + "count", len(rows), + ) + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil +} + +// GetAllAlbumsByLibrary returns albums that have tracks in the given library. +func (l *Library) GetAllAlbumsByLibrary( + libraryID int64, +) ([]Album, error) { + rows, err := l.db.Queries.GetAllAlbumsWithDetailsByLibrary( + l.ctx, libraryID, + ) + if err != nil { + l.logger.Error( + "could not retrieve albums for library", + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get albums for library: %w", err, + ) + } + + l.logger.Info( + "albums for library", + "libraryID", libraryID, + "count", len(rows), + ) + + albums := make([]Album, 0, len(rows)) + + for _, row := range rows { + album := Album{ + ID: row.ID, + Name: row.Name, + ArtistName: row.ArtistName, + } + + if row.Year.Valid { + album.Year = row.Year.Int64 + } + + if row.CoverArtPath != "" { + urls := coverart.ResolveURLs(row.CoverArtPath) + album.CoverArtPath = urls.Original + album.CoverArtSmall = urls.Small + album.CoverArtMedium = urls.Medium + album.CoverArtLarge = urls.Large + } + + albums = append(albums, album) + } + + return albums, nil +} + +// GetAllArtistsByLibrary returns artists that have albums with tracks +// in the given library. +func (l *Library) GetAllArtistsByLibrary( + libraryID int64, +) ([]Artist, error) { + rows, err := l.db.Queries.GetAlbumArtistsByLibrary( + l.ctx, libraryID, + ) + if err != nil { + l.logger.Error( + "could not retrieve artists for library", + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get artists for library: %w", err, + ) + } + + l.logger.Info( + "artists for library", + "libraryID", libraryID, + "count", len(rows), + ) + + artists := make([]Artist, 0, len(rows)) + + for _, row := range rows { + artists = append(artists, Artist{ + ID: row.ID, + Name: row.Name, + }) + } + + return artists, nil +} + +// GetAlbumsByArtistByLibrary returns albums for the given artist +// that have tracks in the given library. +func (l *Library) GetAlbumsByArtistByLibrary( + artistID, libraryID int64, +) ([]Album, error) { + rows, err := l.db.Queries.GetAlbumsByArtistByLibrary( + l.ctx, sqlcgen.GetAlbumsByArtistByLibraryParams{ + ArtistID: artistID, + LibraryID: libraryID, + }, + ) + if err != nil { + l.logger.Error( + "could not retrieve albums for artist in library", + "artistID", artistID, + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get albums for artist in library: %w", + err, + ) + } + + l.logger.Info( + "albums for artist in library", + "artistID", artistID, + "libraryID", libraryID, + "count", len(rows), + ) + + albums := make([]Album, 0, len(rows)) + + for _, row := range rows { + album := Album{ + ID: row.ID, + Name: row.Name, + ArtistName: row.ArtistName, + } + + if row.Year.Valid { + album.Year = row.Year.Int64 + } + + if row.CoverArtPath != "" { + urls := coverart.ResolveURLs(row.CoverArtPath) + album.CoverArtPath = urls.Original + album.CoverArtSmall = urls.Small + album.CoverArtMedium = urls.Medium + album.CoverArtLarge = urls.Large + } + + albums = append(albums, album) + } + + return albums, nil +} + +// GetAllGenresWithCountsByLibrary returns genres with track counts +// scoped to the given library. +func (l *Library) GetAllGenresWithCountsByLibrary( + libraryID int64, +) ([]GenreWithCount, error) { + rows, err := l.db.Queries.GetAllGenresWithCountsByLibrary( + l.ctx, libraryID, + ) + if err != nil { + l.logger.Error( + "could not retrieve genres for library", + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get genres for library: %w", err, + ) + } + + genres := make([]GenreWithCount, 0, len(rows)) + + for _, row := range rows { + genres = append(genres, GenreWithCount{ + Name: row.Name, + TrackCount: row.TrackCount, + }) + } + + return genres, nil +} + +// GetTracksByGenreByLibrary returns tracks tagged with the given +// genre, scoped to the given library. +func (l *Library) GetTracksByGenreByLibrary( + genreName string, libraryID int64, +) ([]Track, error) { + rows, err := l.db.Queries.GetTracksByGenreByLibrary( + l.ctx, sqlcgen.GetTracksByGenreByLibraryParams{ + Name: genreName, + LibraryID: libraryID, + }, + ) + if err != nil { + l.logger.Error( + "could not retrieve tracks for genre in library", + "genre", genreName, + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get tracks for genre in library: %w", + err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil +} + +// GetAlbumTracksByLibrary returns tracks for the given album, +// scoped to the given library. +func (l *Library) GetAlbumTracksByLibrary( + albumID, libraryID int64, +) ([]Track, error) { + rows, err := l.db.Queries.GetAudioFilesByReleaseGroupByLibrary( + l.ctx, sqlcgen.GetAudioFilesByReleaseGroupByLibraryParams{ + ReleaseGroupID: albumID, + LibraryID: libraryID, + }, + ) + if err != nil { + l.logger.Error( + "could not retrieve album tracks for library", + "albumID", albumID, + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get album tracks for library: %w", + err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil +} + +// SearchTracksByLibrary performs an FTS5 search scoped to a specific +// library and returns matching tracks with full metadata. +func (l *Library) SearchTracksByLibrary( + query string, libraryID int64, +) ([]Track, error) { + rows, err := l.db.SearchFTSTracksByLibrary( + query, searchTrackLimit, libraryID, + ) + if err != nil { + l.logger.Error( + "FTS library track search failed", + "query", query, + "libraryID", libraryID, + "error", err, + ) + + return nil, fmt.Errorf( + "search tracks by library failed: %w", err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil +} + // Info contains library metadata enriched with track count // for the frontend settings UI. type Info struct {