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:
@@ -287,6 +287,93 @@ func (d *DB) SearchFTSTracks(
|
|||||||
return results, nil
|
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.
|
// scanSearchRows reads all rows from a query result into a slice.
|
||||||
func scanSearchRows(
|
func scanSearchRows(
|
||||||
rows interface {
|
rows interface {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"yellowjacket/backend/coverart"
|
"yellowjacket/backend/coverart"
|
||||||
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for library queries.
|
// Sentinel errors for library queries.
|
||||||
@@ -444,6 +445,387 @@ func (l *Library) GetAllGenresWithCounts() (
|
|||||||
return genres, nil
|
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
|
// Info contains library metadata enriched with track count
|
||||||
// for the frontend settings UI.
|
// for the frontend settings UI.
|
||||||
type Info struct {
|
type Info struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user