playlist phantom track matching added, updated search queries for efficiency
This commit is contained in:
@@ -211,6 +211,115 @@ func runMigrations(
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 2: add basename column and populate search index.
|
||||
if version < 2 {
|
||||
if err := migration2BasenameAndFTS(
|
||||
ctx, db, logger,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration2BasenameAndFTS adds the basename column to audio_files,
|
||||
// backfills it from file_path, creates the basename index, and
|
||||
// populates the FTS5 search_index table.
|
||||
func migration2BasenameAndFTS(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info(
|
||||
"applying migration 2: basename column + FTS5 search index",
|
||||
)
|
||||
|
||||
// Add basename column (may already exist on fresh DBs).
|
||||
if _, err := db.ExecContext(
|
||||
ctx,
|
||||
"ALTER TABLE audio_files ADD COLUMN basename text NOT NULL DEFAULT ''",
|
||||
); err != nil && !isDuplicateColumnErr(err) {
|
||||
return fmt.Errorf(
|
||||
"migration 2: could not add basename column: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// Backfill basename from file_path for existing rows.
|
||||
// SQLite doesn't have a basename function, so we use
|
||||
// REPLACE to strip directories by finding everything
|
||||
// after the last '/'.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE audio_files
|
||||
SET basename = CASE
|
||||
WHEN INSTR(file_path, '/') > 0
|
||||
THEN SUBSTR(
|
||||
file_path,
|
||||
LENGTH(file_path)
|
||||
- LENGTH(
|
||||
REPLACE(file_path, '/', '')
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
ELSE file_path
|
||||
END
|
||||
WHERE basename = ''
|
||||
`); err != nil {
|
||||
return fmt.Errorf(
|
||||
"migration 2: could not backfill basename: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// Create index (IF NOT EXISTS handles fresh DBs).
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
|
||||
ON audio_files(basename)
|
||||
`); err != nil {
|
||||
return fmt.Errorf(
|
||||
"migration 2: could not create basename index: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// Populate FTS5 search index from existing data.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO search_index(rowid, file_path, title, artist, album)
|
||||
SELECT
|
||||
af.id,
|
||||
af.file_path,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, '')
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg
|
||||
ON rgr.release_group_id = rg.id
|
||||
`); err != nil {
|
||||
return fmt.Errorf(
|
||||
"migration 2: could not populate search index: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
ctx, "PRAGMA user_version = 2",
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not set user_version to 2: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info("migration 2 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
// Package database provides SQLite database access.
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchRow holds a single result from an FTS5 or basename search.
|
||||
type SearchRow struct {
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
}
|
||||
|
||||
// SearchFTS performs a full-text search across title, artist, album,
|
||||
// and file_path using the FTS5 search_index. The query string is
|
||||
// tokenised by FTS5's unicode61 tokeniser.
|
||||
func (d *DB) SearchFTS(
|
||||
query string, limit int,
|
||||
) ([]SearchRow, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Escape double quotes and wrap each token in quotes so
|
||||
// special characters are treated as literals.
|
||||
ftsQuery := buildFTSQuery(query)
|
||||
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, '')
|
||||
FROM search_index si
|
||||
JOIN audio_files af ON af.id = si.rowid
|
||||
LEFT JOIN recordings r
|
||||
ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac
|
||||
ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg
|
||||
ON rgr.release_group_id = rg.id
|
||||
WHERE search_index MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"FTS search failed: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return scanSearchRows(rows)
|
||||
}
|
||||
|
||||
// SearchFTSByFilename searches the file_path column of the FTS5
|
||||
// index for tokens extracted from the given basename.
|
||||
func (d *DB) SearchFTSByFilename(
|
||||
basename string, limit int,
|
||||
) ([]SearchRow, error) {
|
||||
basename = strings.TrimSpace(basename)
|
||||
if basename == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Strip extension and build an FTS query scoped to
|
||||
// the file_path column.
|
||||
stem := stripExtForSearch(basename)
|
||||
tokens := tokeniseForFTS(stem)
|
||||
|
||||
if len(tokens) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ftsQuery := "file_path : " +
|
||||
strings.Join(tokens, " ")
|
||||
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, '')
|
||||
FROM search_index si
|
||||
JOIN audio_files af ON af.id = si.rowid
|
||||
LEFT JOIN recordings r
|
||||
ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac
|
||||
ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg
|
||||
ON rgr.release_group_id = rg.id
|
||||
WHERE search_index MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"FTS filename search failed: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return scanSearchRows(rows)
|
||||
}
|
||||
|
||||
// InsertSearchIndex adds a row to the FTS5 search_index.
|
||||
func (d *DB) InsertSearchIndex(
|
||||
rowid int64,
|
||||
filePath, title, artist, album string,
|
||||
) error {
|
||||
_, err := d.db.ExecContext(d.Ctx, `
|
||||
INSERT INTO search_index(rowid, file_path, title, artist, album)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, rowid, filePath, title, artist, album)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSearchIndex removes a row from the FTS5 search_index.
|
||||
func (d *DB) DeleteSearchIndex(rowid int64) error {
|
||||
_, err := d.db.ExecContext(d.Ctx, `
|
||||
DELETE FROM search_index WHERE rowid = ?
|
||||
`, rowid)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearSearchIndex removes all rows from the FTS5 search_index.
|
||||
func (d *DB) ClearSearchIndex() error {
|
||||
_, err := d.db.ExecContext(d.Ctx, `
|
||||
DELETE FROM search_index
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RebuildSearchIndex repopulates the FTS5 search_index from
|
||||
// scratch using current audio_files + recordings data.
|
||||
func (d *DB) RebuildSearchIndex() error {
|
||||
if err := d.ClearSearchIndex(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not clear search index: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
_, err := d.db.ExecContext(d.Ctx, `
|
||||
INSERT INTO search_index(rowid, file_path, title, artist, album)
|
||||
SELECT
|
||||
af.id,
|
||||
af.file_path,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, '')
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac
|
||||
ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg
|
||||
ON rgr.release_group_id = rg.id
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not rebuild search index: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchTrackRow holds a full track result from an FTS5 search,
|
||||
// matching all 16 columns returned by GetAllTracksWithFullMetadata.
|
||||
type SearchTrackRow struct {
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
ArtistName string
|
||||
TrackNumber sql.NullInt64
|
||||
DiscNumber sql.NullInt64
|
||||
Album string
|
||||
Genre string
|
||||
Year int64
|
||||
Composer string
|
||||
FileType string
|
||||
SampleRate int64
|
||||
BitDepth int64
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
// SearchFTSTracks performs a full-text search and returns full track
|
||||
// metadata for each match. Unlike SearchFTS (which returns only 5
|
||||
// columns), this includes all 16 fields needed for library.Track.
|
||||
func (d *DB) SearchFTSTracks(
|
||||
query string, limit int,
|
||||
) ([]SearchTrackRow, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ftsQuery := buildFTSQuery(query)
|
||||
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g.name, '||')
|
||||
FROM recording_genres rg_sub
|
||||
JOIN genres g ON rg_sub.genre_id = g.id
|
||||
WHERE rg_sub.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size
|
||||
FROM search_index si
|
||||
JOIN audio_files af ON af.id = si.rowid
|
||||
LEFT JOIN recordings r
|
||||
ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac
|
||||
ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg
|
||||
ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN file_types ft
|
||||
ON af.file_type_id = ft.id
|
||||
WHERE search_index MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"FTS 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 search track row: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"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 {
|
||||
Next() bool
|
||||
Scan(dest ...any) error
|
||||
Err() error
|
||||
},
|
||||
) ([]SearchRow, error) {
|
||||
var results []SearchRow
|
||||
|
||||
for rows.Next() {
|
||||
var r SearchRow
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.FilePath,
|
||||
&r.LengthMilliseconds,
|
||||
&r.Title,
|
||||
&r.Artist,
|
||||
&r.Album,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not scan search row: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"search row iteration error: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// buildFTSQuery converts a user query string into an FTS5 query.
|
||||
// Each word is quoted to escape special characters and combined
|
||||
// with implicit AND.
|
||||
func buildFTSQuery(query string) string {
|
||||
tokens := tokeniseForFTS(query)
|
||||
if len(tokens) == 0 {
|
||||
return query
|
||||
}
|
||||
|
||||
return strings.Join(tokens, " ")
|
||||
}
|
||||
|
||||
// tokeniseForFTS splits a string on whitespace and common
|
||||
// separators, returning quoted FTS5 tokens.
|
||||
func tokeniseForFTS(s string) []string {
|
||||
// Split on whitespace, hyphens, underscores, dots.
|
||||
fields := strings.FieldsFunc(
|
||||
s, func(r rune) bool {
|
||||
return r == ' ' || r == '-' ||
|
||||
r == '_' || r == '.' ||
|
||||
r == '/' || r == '\\'
|
||||
},
|
||||
)
|
||||
|
||||
tokens := make([]string, 0, len(fields))
|
||||
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Escape any double quotes inside the token.
|
||||
f = strings.ReplaceAll(f, `"`, `""`)
|
||||
tokens = append(tokens, `"`+f+`"`)
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
// stripExtForSearch removes the file extension from a string.
|
||||
func stripExtForSearch(s string) string {
|
||||
if idx := strings.LastIndexByte(s, '.'); idx > 0 {
|
||||
return s[:idx]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
-- name: CreateAudioFile :one
|
||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAudioFile :one
|
||||
@@ -12,7 +12,7 @@ WHERE file_path = ? LIMIT 1;
|
||||
|
||||
-- name: UpdateAudioFile :exec
|
||||
UPDATE audio_files
|
||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
|
||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: UpdateAudioFileRecording :exec
|
||||
@@ -102,6 +102,25 @@ LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
|
||||
|
||||
-- name: SearchAudioFilesByBasename :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist,
|
||||
COALESCE(rg.name, '') AS album
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
WHERE af.basename = ?
|
||||
LIMIT ?;
|
||||
|
||||
-- name: DeleteAllAudioFiles :exec
|
||||
DELETE FROM audio_files;
|
||||
|
||||
|
||||
@@ -22,3 +22,50 @@ DELETE FROM recording_genres;
|
||||
|
||||
-- name: DeleteAllGenres :exec
|
||||
DELETE FROM genres;
|
||||
|
||||
-- name: GetTracksByGenre :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rlg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
||||
FROM recording_genres rg2
|
||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
||||
WHERE rg2.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE g.name = ?
|
||||
ORDER BY r.name;
|
||||
|
||||
-- name: GetAllGenresWithCounts :many
|
||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name;
|
||||
|
||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS audio_files (
|
||||
channels int NOT NULL DEFAULT 0,
|
||||
bitrate int NOT NULL DEFAULT 0,
|
||||
file_size int NOT NULL DEFAULT 0,
|
||||
basename text NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
|
||||
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
||||
file_path,
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
content='',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
);
|
||||
@@ -22,8 +22,8 @@ func (q *Queries) CountAudioFiles(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
const createAudioFile = `-- name: CreateAudioFile :one
|
||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size
|
||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename
|
||||
`
|
||||
|
||||
type CreateAudioFileParams struct {
|
||||
@@ -36,6 +36,7 @@ type CreateAudioFileParams struct {
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
Basename string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) {
|
||||
@@ -49,6 +50,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
||||
arg.Channels,
|
||||
arg.Bitrate,
|
||||
arg.FileSize,
|
||||
arg.Basename,
|
||||
)
|
||||
var i AudioFile
|
||||
err := row.Scan(
|
||||
@@ -62,6 +64,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
&i.Basename,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -118,7 +121,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
|
||||
}
|
||||
|
||||
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
|
||||
@@ -141,6 +144,7 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
&i.Basename,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -302,7 +306,7 @@ func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTra
|
||||
}
|
||||
|
||||
const getAudioFile = `-- name: GetAudioFile :one
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
@@ -320,12 +324,13 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
&i.Basename,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files
|
||||
WHERE file_path = ? LIMIT 1
|
||||
`
|
||||
|
||||
@@ -343,6 +348,7 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
&i.Basename,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -403,7 +409,7 @@ func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupI
|
||||
}
|
||||
|
||||
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files
|
||||
WHERE recording_id = 0
|
||||
`
|
||||
|
||||
@@ -427,6 +433,7 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
&i.Basename,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -492,9 +499,71 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (
|
||||
return i, err
|
||||
}
|
||||
|
||||
const searchAudioFilesByBasename = `-- name: SearchAudioFilesByBasename :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist,
|
||||
COALESCE(rg.name, '') AS album
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
WHERE af.basename = ?
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
type SearchAudioFilesByBasenameParams struct {
|
||||
Basename string
|
||||
Limit int64
|
||||
}
|
||||
|
||||
type SearchAudioFilesByBasenameRow struct {
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
}
|
||||
|
||||
func (q *Queries) SearchAudioFilesByBasename(ctx context.Context, arg SearchAudioFilesByBasenameParams) ([]SearchAudioFilesByBasenameRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, searchAudioFilesByBasename, arg.Basename, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []SearchAudioFilesByBasenameRow
|
||||
for rows.Next() {
|
||||
var i SearchAudioFilesByBasenameRow
|
||||
if err := rows.Scan(
|
||||
&i.FilePath,
|
||||
&i.LengthMilliseconds,
|
||||
&i.Title,
|
||||
&i.Artist,
|
||||
&i.Album,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateAudioFile = `-- name: UpdateAudioFile :exec
|
||||
UPDATE audio_files
|
||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
|
||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -508,6 +577,7 @@ type UpdateAudioFileParams struct {
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
Basename string
|
||||
ID int64
|
||||
}
|
||||
|
||||
@@ -522,6 +592,7 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
|
||||
arg.Channels,
|
||||
arg.Bitrate,
|
||||
arg.FileSize,
|
||||
arg.Basename,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -7,6 +7,7 @@ package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
|
||||
@@ -52,6 +53,42 @@ func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many
|
||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name
|
||||
`
|
||||
|
||||
type GetAllGenresWithCountsRow struct {
|
||||
Name string
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAllGenresWithCountsRow
|
||||
for rows.Next() {
|
||||
var i GetAllGenresWithCountsRow
|
||||
if err := rows.Scan(&i.Name, &i.TrackCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
||||
SELECT g.id, g.name
|
||||
FROM genres g
|
||||
@@ -82,6 +119,106 @@ func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getTracksByGenre = `-- name: GetTracksByGenre :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rlg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
||||
FROM recording_genres rg2
|
||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
||||
WHERE rg2.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE g.name = ?
|
||||
ORDER BY r.name
|
||||
`
|
||||
|
||||
type GetTracksByGenreRow struct {
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
ArtistName string
|
||||
TrackNumber sql.NullInt64
|
||||
DiscNumber sql.NullInt64
|
||||
Album string
|
||||
Genre string
|
||||
Year int64
|
||||
Composer string
|
||||
FileType string
|
||||
SampleRate int64
|
||||
BitDepth int64
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTracksByGenre, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTracksByGenreRow
|
||||
for rows.Next() {
|
||||
var i GetTracksByGenreRow
|
||||
if err := rows.Scan(
|
||||
&i.FilePath,
|
||||
&i.LengthMilliseconds,
|
||||
&i.Title,
|
||||
&i.ArtistName,
|
||||
&i.TrackNumber,
|
||||
&i.DiscNumber,
|
||||
&i.Album,
|
||||
&i.Genre,
|
||||
&i.Year,
|
||||
&i.Composer,
|
||||
&i.FileType,
|
||||
&i.SampleRate,
|
||||
&i.BitDepth,
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertGenre = `-- name: UpsertGenre :one
|
||||
INSERT INTO genres (name) VALUES (?)
|
||||
ON CONFLICT(name) DO UPDATE SET name = name
|
||||
|
||||
@@ -36,6 +36,7 @@ type AudioFile struct {
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
Basename string
|
||||
}
|
||||
|
||||
type CoverArt struct {
|
||||
@@ -128,3 +129,10 @@ type ReleaseGroupRecording struct {
|
||||
TrackNumber sql.NullInt64
|
||||
DiscNumber sql.NullInt64
|
||||
}
|
||||
|
||||
type SearchIndex struct {
|
||||
FilePath string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user