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
This commit is contained in:
2026-03-16 09:29:08 -04:00
parent 5cc58ce66a
commit 5f7de5060a
2 changed files with 469 additions and 0 deletions
+87
View File
@@ -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 {
+382
View File
@@ -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 {