audio file info added, fixed right click selecting.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||||
|
|
||||||
@@ -97,6 +98,14 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run versioned schema migrations for columns that cannot be
|
||||||
|
// added with CREATE TABLE IF NOT EXISTS on existing databases.
|
||||||
|
if err := runMigrations(dbCtx, db, logger); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"could not run schema migrations: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Remove orphaned playlist_tracks left behind by past deletes
|
// Remove orphaned playlist_tracks left behind by past deletes
|
||||||
// that ran without foreign key enforcement.
|
// that ran without foreign key enforcement.
|
||||||
orphanResult, err := db.ExecContext(
|
orphanResult, err := db.ExecContext(
|
||||||
@@ -140,3 +149,77 @@ func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) {
|
|||||||
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) {
|
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) {
|
||||||
return d.db.QueryContext(d.Ctx, query, args...)
|
return d.db.QueryContext(d.Ctx, query, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runMigrations applies incremental schema changes using SQLite's
|
||||||
|
// PRAGMA user_version as the version tracker. Each migration runs
|
||||||
|
// once and bumps the version so it is never re-applied.
|
||||||
|
func runMigrations(
|
||||||
|
ctx context.Context,
|
||||||
|
db *sql.DB,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) error {
|
||||||
|
var version int
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(
|
||||||
|
ctx, "PRAGMA user_version",
|
||||||
|
).Scan(&version); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not read user_version: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug(
|
||||||
|
"current schema version",
|
||||||
|
"user_version", version,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Migration 1: add audio-property columns to audio_files.
|
||||||
|
if version < 1 {
|
||||||
|
logger.Info("applying migration 1: audio file properties")
|
||||||
|
|
||||||
|
cols := []string{
|
||||||
|
"sample_rate int NOT NULL DEFAULT 0",
|
||||||
|
"bit_depth int NOT NULL DEFAULT 0",
|
||||||
|
"channels int NOT NULL DEFAULT 0",
|
||||||
|
"bitrate int NOT NULL DEFAULT 0",
|
||||||
|
"file_size int NOT NULL DEFAULT 0",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, col := range cols {
|
||||||
|
stmt := "ALTER TABLE audio_files ADD COLUMN " + col
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
// Column may already exist on a fresh DB that
|
||||||
|
// ran the updated CREATE TABLE. SQLite returns
|
||||||
|
// "duplicate column name" in that case.
|
||||||
|
if isDuplicateColumnErr(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf(
|
||||||
|
"migration 1 failed (%s): %w", col, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx, "PRAGMA user_version = 1",
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not set user_version to 1: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDuplicateColumnErr returns true when the error is SQLite's
|
||||||
|
// "duplicate column name" error from an ALTER TABLE ADD COLUMN
|
||||||
|
// on a column that already exists.
|
||||||
|
func isDuplicateColumnErr(err error) bool {
|
||||||
|
return err != nil &&
|
||||||
|
strings.Contains(
|
||||||
|
err.Error(), "duplicate column name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- name: CreateAudioFile :one
|
-- name: CreateAudioFile :one
|
||||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, ?, ?)
|
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetAudioFile :one
|
-- name: GetAudioFile :one
|
||||||
@@ -12,12 +12,12 @@ WHERE file_path = ? LIMIT 1;
|
|||||||
|
|
||||||
-- name: UpdateAudioFile :exec
|
-- name: UpdateAudioFile :exec
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
|
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: UpdateAudioFileRecording :exec
|
-- name: UpdateAudioFileRecording :exec
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET recording_id = ?
|
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteAudioFile :exec
|
-- name: DeleteAudioFile :exec
|
||||||
@@ -89,7 +89,12 @@ SELECT
|
|||||||
) AS TEXT) AS genre,
|
) AS TEXT) AS genre,
|
||||||
COALESCE(r.year, 0) AS year,
|
COALESCE(r.year, 0) AS year,
|
||||||
COALESCE(r.composer, '') AS composer,
|
COALESCE(r.composer, '') AS composer,
|
||||||
COALESCE(ft.extension, '') AS file_type
|
COALESCE(ft.extension, '') AS file_type,
|
||||||
|
af.sample_rate,
|
||||||
|
af.bit_depth,
|
||||||
|
af.channels,
|
||||||
|
af.bitrate,
|
||||||
|
af.file_size
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
JOIN recordings r ON af.recording_id = r.id
|
JOIN recordings r ON af.recording_id = r.id
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ CREATE TABLE IF NOT EXISTS audio_files (
|
|||||||
length_milliseconds int NOT NULL,
|
length_milliseconds int NOT NULL,
|
||||||
file_type_id int NOT NULL,
|
file_type_id int NOT NULL,
|
||||||
recording_id int NOT NULL,
|
recording_id int NOT NULL,
|
||||||
|
sample_rate int NOT NULL DEFAULT 0,
|
||||||
|
bit_depth int NOT NULL DEFAULT 0,
|
||||||
|
channels int NOT NULL DEFAULT 0,
|
||||||
|
bitrate int NOT NULL DEFAULT 0,
|
||||||
|
file_size int NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
|
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
|
||||||
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ func (q *Queries) CountAudioFiles(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createAudioFile = `-- name: CreateAudioFile :one
|
const createAudioFile = `-- name: CreateAudioFile :one
|
||||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, ?, ?)
|
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
|
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAudioFileParams struct {
|
type CreateAudioFileParams struct {
|
||||||
@@ -31,6 +31,11 @@ type CreateAudioFileParams struct {
|
|||||||
LengthMilliseconds int64
|
LengthMilliseconds int64
|
||||||
FileTypeID int64
|
FileTypeID int64
|
||||||
RecordingID int64
|
RecordingID int64
|
||||||
|
SampleRate int64
|
||||||
|
BitDepth int64
|
||||||
|
Channels int64
|
||||||
|
Bitrate int64
|
||||||
|
FileSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) {
|
func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) {
|
||||||
@@ -39,6 +44,11 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
|||||||
arg.LengthMilliseconds,
|
arg.LengthMilliseconds,
|
||||||
arg.FileTypeID,
|
arg.FileTypeID,
|
||||||
arg.RecordingID,
|
arg.RecordingID,
|
||||||
|
arg.SampleRate,
|
||||||
|
arg.BitDepth,
|
||||||
|
arg.Channels,
|
||||||
|
arg.Bitrate,
|
||||||
|
arg.FileSize,
|
||||||
)
|
)
|
||||||
var i AudioFile
|
var i AudioFile
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -47,6 +57,11 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
|||||||
&i.LengthMilliseconds,
|
&i.LengthMilliseconds,
|
||||||
&i.FileTypeID,
|
&i.FileTypeID,
|
||||||
&i.RecordingID,
|
&i.RecordingID,
|
||||||
|
&i.SampleRate,
|
||||||
|
&i.BitDepth,
|
||||||
|
&i.Channels,
|
||||||
|
&i.Bitrate,
|
||||||
|
&i.FileSize,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -103,7 +118,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
|
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
|
||||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
|
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
|
||||||
@@ -121,6 +136,11 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
|
|||||||
&i.LengthMilliseconds,
|
&i.LengthMilliseconds,
|
||||||
&i.FileTypeID,
|
&i.FileTypeID,
|
||||||
&i.RecordingID,
|
&i.RecordingID,
|
||||||
|
&i.SampleRate,
|
||||||
|
&i.BitDepth,
|
||||||
|
&i.Channels,
|
||||||
|
&i.Bitrate,
|
||||||
|
&i.FileSize,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -208,7 +228,12 @@ SELECT
|
|||||||
) AS TEXT) AS genre,
|
) AS TEXT) AS genre,
|
||||||
COALESCE(r.year, 0) AS year,
|
COALESCE(r.year, 0) AS year,
|
||||||
COALESCE(r.composer, '') AS composer,
|
COALESCE(r.composer, '') AS composer,
|
||||||
COALESCE(ft.extension, '') AS file_type
|
COALESCE(ft.extension, '') AS file_type,
|
||||||
|
af.sample_rate,
|
||||||
|
af.bit_depth,
|
||||||
|
af.channels,
|
||||||
|
af.bitrate,
|
||||||
|
af.file_size
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
JOIN recordings r ON af.recording_id = r.id
|
JOIN recordings r ON af.recording_id = r.id
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||||
@@ -229,6 +254,11 @@ type GetAllTracksWithFullMetadataRow struct {
|
|||||||
Year int64
|
Year int64
|
||||||
Composer string
|
Composer string
|
||||||
FileType string
|
FileType string
|
||||||
|
SampleRate int64
|
||||||
|
BitDepth int64
|
||||||
|
Channels int64
|
||||||
|
Bitrate int64
|
||||||
|
FileSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) {
|
func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) {
|
||||||
@@ -252,6 +282,11 @@ func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTra
|
|||||||
&i.Year,
|
&i.Year,
|
||||||
&i.Composer,
|
&i.Composer,
|
||||||
&i.FileType,
|
&i.FileType,
|
||||||
|
&i.SampleRate,
|
||||||
|
&i.BitDepth,
|
||||||
|
&i.Channels,
|
||||||
|
&i.Bitrate,
|
||||||
|
&i.FileSize,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -267,7 +302,7 @@ func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFile = `-- name: GetAudioFile :one
|
const getAudioFile = `-- name: GetAudioFile :one
|
||||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||||
WHERE id = ? LIMIT 1
|
WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -280,12 +315,17 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
|
|||||||
&i.LengthMilliseconds,
|
&i.LengthMilliseconds,
|
||||||
&i.FileTypeID,
|
&i.FileTypeID,
|
||||||
&i.RecordingID,
|
&i.RecordingID,
|
||||||
|
&i.SampleRate,
|
||||||
|
&i.BitDepth,
|
||||||
|
&i.Channels,
|
||||||
|
&i.Bitrate,
|
||||||
|
&i.FileSize,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
|
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
|
||||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||||
WHERE file_path = ? LIMIT 1
|
WHERE file_path = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -298,6 +338,11 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
|
|||||||
&i.LengthMilliseconds,
|
&i.LengthMilliseconds,
|
||||||
&i.FileTypeID,
|
&i.FileTypeID,
|
||||||
&i.RecordingID,
|
&i.RecordingID,
|
||||||
|
&i.SampleRate,
|
||||||
|
&i.BitDepth,
|
||||||
|
&i.Channels,
|
||||||
|
&i.Bitrate,
|
||||||
|
&i.FileSize,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -358,7 +403,7 @@ func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupI
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
|
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
|
||||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files
|
||||||
WHERE recording_id = 0
|
WHERE recording_id = 0
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -377,6 +422,11 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
|
|||||||
&i.LengthMilliseconds,
|
&i.LengthMilliseconds,
|
||||||
&i.FileTypeID,
|
&i.FileTypeID,
|
||||||
&i.RecordingID,
|
&i.RecordingID,
|
||||||
|
&i.SampleRate,
|
||||||
|
&i.BitDepth,
|
||||||
|
&i.Channels,
|
||||||
|
&i.Bitrate,
|
||||||
|
&i.FileSize,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -444,7 +494,7 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (
|
|||||||
|
|
||||||
const updateAudioFile = `-- name: UpdateAudioFile :exec
|
const updateAudioFile = `-- name: UpdateAudioFile :exec
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
|
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -453,6 +503,11 @@ type UpdateAudioFileParams struct {
|
|||||||
LengthMilliseconds int64
|
LengthMilliseconds int64
|
||||||
FileTypeID int64
|
FileTypeID int64
|
||||||
RecordingID int64
|
RecordingID int64
|
||||||
|
SampleRate int64
|
||||||
|
BitDepth int64
|
||||||
|
Channels int64
|
||||||
|
Bitrate int64
|
||||||
|
FileSize int64
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +517,11 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
|
|||||||
arg.LengthMilliseconds,
|
arg.LengthMilliseconds,
|
||||||
arg.FileTypeID,
|
arg.FileTypeID,
|
||||||
arg.RecordingID,
|
arg.RecordingID,
|
||||||
|
arg.SampleRate,
|
||||||
|
arg.BitDepth,
|
||||||
|
arg.Channels,
|
||||||
|
arg.Bitrate,
|
||||||
|
arg.FileSize,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -469,16 +529,29 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
|
|||||||
|
|
||||||
const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec
|
const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET recording_id = ?
|
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAudioFileRecordingParams struct {
|
type UpdateAudioFileRecordingParams struct {
|
||||||
RecordingID int64
|
RecordingID int64
|
||||||
|
SampleRate int64
|
||||||
|
BitDepth int64
|
||||||
|
Channels int64
|
||||||
|
Bitrate int64
|
||||||
|
FileSize int64
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateAudioFileRecording(ctx context.Context, arg UpdateAudioFileRecordingParams) error {
|
func (q *Queries) UpdateAudioFileRecording(ctx context.Context, arg UpdateAudioFileRecordingParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, updateAudioFileRecording, arg.RecordingID, arg.ID)
|
_, err := q.db.ExecContext(ctx, updateAudioFileRecording,
|
||||||
|
arg.RecordingID,
|
||||||
|
arg.SampleRate,
|
||||||
|
arg.BitDepth,
|
||||||
|
arg.Channels,
|
||||||
|
arg.Bitrate,
|
||||||
|
arg.FileSize,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ type AudioFile struct {
|
|||||||
LengthMilliseconds int64
|
LengthMilliseconds int64
|
||||||
FileTypeID int64
|
FileTypeID int64
|
||||||
RecordingID int64
|
RecordingID int64
|
||||||
|
SampleRate int64
|
||||||
|
BitDepth int64
|
||||||
|
Channels int64
|
||||||
|
Bitrate int64
|
||||||
|
FileSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoverArt struct {
|
type CoverArt struct {
|
||||||
|
|||||||
@@ -576,6 +576,7 @@ type importResult struct {
|
|||||||
fileType metadata.AudioFileExtension
|
fileType metadata.AudioFileExtension
|
||||||
lengthMillis int64
|
lengthMillis int64
|
||||||
tags *metadata.TrackMetadata
|
tags *metadata.TrackMetadata
|
||||||
|
audioProps *metadata.AudioProperties
|
||||||
existingFileID int64 // non-zero if this is an update
|
existingFileID int64 // non-zero if this is an update
|
||||||
needsUpdate bool
|
needsUpdate bool
|
||||||
}
|
}
|
||||||
@@ -597,7 +598,7 @@ func (l *Library) extractAudioMetadata(
|
|||||||
// Skip duration decode if we already have it from a previous import.
|
// Skip duration decode if we already have it from a previous import.
|
||||||
skipDuration := work.needsUpdate && work.existingLength > 0
|
skipDuration := work.needsUpdate && work.existingLength > 0
|
||||||
|
|
||||||
tags, lengthMillis, timing, err := metadata.ExtractAllMetadata(
|
tags, lengthMillis, audioProps, timing, err := metadata.ExtractAllMetadata(
|
||||||
work.absolutePath, skipDuration,
|
work.absolutePath, skipDuration,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -618,6 +619,7 @@ func (l *Library) extractAudioMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.tags = tags
|
result.tags = tags
|
||||||
|
result.audioProps = audioProps
|
||||||
|
|
||||||
if skipDuration {
|
if skipDuration {
|
||||||
result.lengthMillis = work.existingLength
|
result.lengthMillis = work.existingLength
|
||||||
@@ -721,6 +723,11 @@ func (l *Library) saveAudioFile(
|
|||||||
return fmt.Errorf("could not process metadata: %w", err)
|
return fmt.Errorf("could not process metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
props := result.audioProps
|
||||||
|
if props == nil {
|
||||||
|
props = &metadata.AudioProperties{}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := q.CreateAudioFile(
|
if _, err := q.CreateAudioFile(
|
||||||
l.ctx, sqlcgen.CreateAudioFileParams{
|
l.ctx, sqlcgen.CreateAudioFileParams{
|
||||||
FilePath: result.absolutePath,
|
FilePath: result.absolutePath,
|
||||||
@@ -732,6 +739,11 @@ func (l *Library) saveAudioFile(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecordingID: recordingID,
|
RecordingID: recordingID,
|
||||||
|
SampleRate: int64(props.SampleRate),
|
||||||
|
BitDepth: int64(props.BitDepth),
|
||||||
|
Channels: int64(props.Channels),
|
||||||
|
Bitrate: int64(props.Bitrate),
|
||||||
|
FileSize: props.FileSize,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"could not save audio file to db: %w", err,
|
"could not save audio file to db: %w", err,
|
||||||
@@ -768,9 +780,19 @@ func (l *Library) updateAudioFileMetadata(
|
|||||||
return fmt.Errorf("could not process metadata: %w", err)
|
return fmt.Errorf("could not process metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
props := result.audioProps
|
||||||
|
if props == nil {
|
||||||
|
props = &metadata.AudioProperties{}
|
||||||
|
}
|
||||||
|
|
||||||
if err := q.UpdateAudioFileRecording(
|
if err := q.UpdateAudioFileRecording(
|
||||||
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
|
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
|
||||||
RecordingID: recordingID,
|
RecordingID: recordingID,
|
||||||
|
SampleRate: int64(props.SampleRate),
|
||||||
|
BitDepth: int64(props.BitDepth),
|
||||||
|
Channels: int64(props.Channels),
|
||||||
|
Bitrate: int64(props.Bitrate),
|
||||||
|
FileSize: props.FileSize,
|
||||||
ID: result.existingFileID,
|
ID: result.existingFileID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ type Track struct {
|
|||||||
Year int64
|
Year int64
|
||||||
Composer string
|
Composer string
|
||||||
FileType string
|
FileType string
|
||||||
|
SampleRate int64
|
||||||
|
BitDepth int64
|
||||||
|
Channels int64
|
||||||
|
Bitrate int64
|
||||||
|
FileSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// genreDelimiter is the separator used by GROUP_CONCAT in the
|
// genreDelimiter is the separator used by GROUP_CONCAT in the
|
||||||
@@ -100,6 +105,11 @@ func (l *Library) GetAllTracks() ([]Track, error) {
|
|||||||
Year: row.Year,
|
Year: row.Year,
|
||||||
Composer: row.Composer,
|
Composer: row.Composer,
|
||||||
FileType: row.FileType,
|
FileType: row.FileType,
|
||||||
|
SampleRate: row.SampleRate,
|
||||||
|
BitDepth: row.BitDepth,
|
||||||
|
Channels: row.Channels,
|
||||||
|
Bitrate: row.Bitrate,
|
||||||
|
FileSize: row.FileSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks = append(tracks, track)
|
tracks = append(tracks, track)
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// getTrackDuration returns the duration of an audio file in
|
// getTrackDuration returns the duration of an audio file in
|
||||||
// milliseconds. For MP3 files it uses a fast header-only parser
|
// milliseconds together with its audio stream properties. For MP3
|
||||||
// (Xing/VBRI/CBR); for other formats it falls back to a full
|
// files it uses a fast header-only parser (Xing/VBRI/CBR); for FLAC
|
||||||
// decode via beep which is already O(1) for FLAC, OGG, and WAV.
|
// it reads the StreamInfo block; for other formats it falls back to
|
||||||
|
// beep which is already O(1) for OGG and WAV.
|
||||||
//
|
//
|
||||||
// The file position is undefined after this call.
|
// The file position is undefined after this call.
|
||||||
func getTrackDuration(f *os.File) (int64, error) {
|
func getTrackDuration(
|
||||||
|
f *os.File,
|
||||||
|
) (int64, *AudioProperties, error) {
|
||||||
ext := filepath.Ext(f.Name())
|
ext := filepath.Ext(f.Name())
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -26,7 +29,9 @@ func getTrackDuration(f *os.File) (int64, error) {
|
|||||||
// (reads headers/metadata only, no full audio decode).
|
// (reads headers/metadata only, no full audio decode).
|
||||||
streamer, format, err := DecodeFile(f)
|
streamer, format, err := DecodeFile(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("error decoding file: %w", err)
|
return 0, nil, fmt.Errorf(
|
||||||
|
"error decoding file: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
lengthMillis := int64(
|
lengthMillis := int64(
|
||||||
@@ -35,5 +40,11 @@ func getTrackDuration(f *os.File) (int64, error) {
|
|||||||
)
|
)
|
||||||
_ = streamer.Close()
|
_ = streamer.Close()
|
||||||
|
|
||||||
return lengthMillis, nil
|
props := &AudioProperties{
|
||||||
|
SampleRate: int(format.SampleRate),
|
||||||
|
BitDepth: format.Precision * 8,
|
||||||
|
Channels: format.NumChannels,
|
||||||
|
}
|
||||||
|
|
||||||
|
return lengthMillis, props, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ const streamInfoBlockType = 0
|
|||||||
|
|
||||||
// getFlacDuration computes the duration of a FLAC file in
|
// getFlacDuration computes the duration of a FLAC file in
|
||||||
// milliseconds by reading only the StreamInfo metadata block header.
|
// milliseconds by reading only the StreamInfo metadata block header.
|
||||||
// It handles an optional prepended ID3v2 tag by seeking past it.
|
// It also extracts sample rate, bit depth, and channel count from
|
||||||
|
// the same header. It handles an optional prepended ID3v2 tag by
|
||||||
|
// seeking past it.
|
||||||
//
|
//
|
||||||
// This replaces the previous beep/mewkiz-flac decode path which has
|
// This replaces the previous beep/mewkiz-flac decode path which has
|
||||||
// a bug in its ID3v2 skip logic (bufio over bufseekio causes a
|
// a bug in its ID3v2 skip logic (bufio over bufseekio causes a
|
||||||
@@ -47,23 +49,25 @@ const streamInfoBlockType = 0
|
|||||||
// The file position is undefined after this call.
|
// The file position is undefined after this call.
|
||||||
//
|
//
|
||||||
//nolint:mnd // byte offsets and bit shifts from the FLAC spec.
|
//nolint:mnd // byte offsets and bit shifts from the FLAC spec.
|
||||||
func getFlacDuration(f *os.File) (int64, error) {
|
func getFlacDuration(
|
||||||
|
f *os.File,
|
||||||
|
) (int64, *AudioProperties, error) {
|
||||||
audioStart, err := skipID3v2(f)
|
audioStart, err := skipID3v2(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
return 0, nil, fmt.Errorf("skipping ID3v2: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the 4-byte FLAC signature.
|
// Read the 4-byte FLAC signature.
|
||||||
var sig [4]byte
|
var sig [4]byte
|
||||||
|
|
||||||
if _, err := f.ReadAt(sig[:], audioStart); err != nil {
|
if _, err := f.ReadAt(sig[:], audioStart); err != nil {
|
||||||
return 0, fmt.Errorf(
|
return 0, nil, fmt.Errorf(
|
||||||
"reading FLAC signature: %w", err,
|
"reading FLAC signature: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sig != flacSignatureBytes {
|
if sig != flacSignatureBytes {
|
||||||
return 0, fmt.Errorf(
|
return 0, nil, fmt.Errorf(
|
||||||
"%w: expected %q, got %q",
|
"%w: expected %q, got %q",
|
||||||
errInvalidFLACSignature, flacSignatureBytes, sig,
|
errInvalidFLACSignature, flacSignatureBytes, sig,
|
||||||
)
|
)
|
||||||
@@ -76,7 +80,7 @@ func getFlacDuration(f *os.File) (int64, error) {
|
|||||||
if _, err := f.ReadAt(
|
if _, err := f.ReadAt(
|
||||||
mbh[:], audioStart+4,
|
mbh[:], audioStart+4,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return 0, fmt.Errorf(
|
return 0, nil, fmt.Errorf(
|
||||||
"reading metadata block header: %w", err,
|
"reading metadata block header: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -89,7 +93,7 @@ func getFlacDuration(f *os.File) (int64, error) {
|
|||||||
|
|
||||||
if blockType != streamInfoBlockType ||
|
if blockType != streamInfoBlockType ||
|
||||||
blockLen != streamInfoLength {
|
blockLen != streamInfoLength {
|
||||||
return 0, fmt.Errorf(
|
return 0, nil, fmt.Errorf(
|
||||||
"%w: type=%d, length=%d",
|
"%w: type=%d, length=%d",
|
||||||
errInvalidStreamInfo, blockType, blockLen,
|
errInvalidStreamInfo, blockType, blockLen,
|
||||||
)
|
)
|
||||||
@@ -101,41 +105,50 @@ func getFlacDuration(f *os.File) (int64, error) {
|
|||||||
if _, err := f.ReadAt(
|
if _, err := f.ReadAt(
|
||||||
si[:], audioStart+8,
|
si[:], audioStart+8,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return 0, fmt.Errorf(
|
return 0, nil, fmt.Errorf(
|
||||||
"reading StreamInfo block: %w", err,
|
"reading StreamInfo block: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
sampleRate, totalSamples := parseFlacStreamInfo(si)
|
sampleRate, totalSamples, channels, bitDepth := parseFlacStreamInfo(si)
|
||||||
|
|
||||||
if sampleRate == 0 {
|
if sampleRate == 0 {
|
||||||
return 0, errZeroSampleRate
|
return 0, nil, errZeroSampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
durationMS := int64(totalSamples) * 1000 /
|
durationMS := int64(totalSamples) * 1000 /
|
||||||
int64(sampleRate)
|
int64(sampleRate)
|
||||||
|
|
||||||
return durationMS, nil
|
props := &AudioProperties{
|
||||||
|
SampleRate: int(sampleRate),
|
||||||
|
BitDepth: int(bitDepth),
|
||||||
|
Channels: int(channels),
|
||||||
|
}
|
||||||
|
|
||||||
|
return durationMS, props, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFlacStreamInfo extracts the sample rate (20 bits) and total
|
// parseFlacStreamInfo extracts key fields from a 34-byte FLAC
|
||||||
// sample count (36 bits) from a 34-byte FLAC StreamInfo body.
|
// StreamInfo body.
|
||||||
//
|
//
|
||||||
// StreamInfo layout (bytes 10-17 contain the fields we need):
|
// StreamInfo layout (bytes 10-17 contain the fields we need):
|
||||||
//
|
//
|
||||||
// bits 0-19: sample rate in Hz (20 bits)
|
// bits 0-19: sample rate in Hz (20 bits)
|
||||||
// bits 20-22: number of channels -1 (3 bits, unused here)
|
// bits 20-22: number of channels -1 (3 bits)
|
||||||
// bits 23-27: bits per sample -1 (5 bits, unused here)
|
// bits 23-27: bits per sample -1 (5 bits)
|
||||||
// bits 28-63: total samples (36 bits)
|
// bits 28-63: total samples (36 bits)
|
||||||
//
|
//
|
||||||
//nolint:mnd // bit offsets from the FLAC spec.
|
//nolint:mnd // bit offsets from the FLAC spec.
|
||||||
func parseFlacStreamInfo(
|
func parseFlacStreamInfo(
|
||||||
si [streamInfoLength]byte,
|
si [streamInfoLength]byte,
|
||||||
) (sampleRate uint32, totalSamples uint64) {
|
) (sampleRate uint32, totalSamples uint64, channels uint32, bitDepth uint32) {
|
||||||
// Bytes 10-13 packed as big-endian uint32 contain sample rate
|
// Bytes 10-13 packed as big-endian uint32 contain sample rate
|
||||||
// in the upper 20 bits.
|
// in the upper 20 bits, channels in bits 9-11, and bits per
|
||||||
|
// sample in bits 4-8.
|
||||||
packed := binary.BigEndian.Uint32(si[10:14])
|
packed := binary.BigEndian.Uint32(si[10:14])
|
||||||
sampleRate = packed >> 12
|
sampleRate = packed >> 12
|
||||||
|
channels = (packed>>9)&0x07 + 1
|
||||||
|
bitDepth = (packed>>4)&0x1F + 1
|
||||||
|
|
||||||
// Total samples: 4 low bits of byte 13, then bytes 14-17.
|
// Total samples: 4 low bits of byte 13, then bytes 14-17.
|
||||||
totalSamples = uint64(si[13]&0x0F)<<32 |
|
totalSamples = uint64(si[13]&0x0F)<<32 |
|
||||||
@@ -144,5 +157,5 @@ func parseFlacStreamInfo(
|
|||||||
uint64(si[16])<<8 |
|
uint64(si[16])<<8 |
|
||||||
uint64(si[17])
|
uint64(si[17])
|
||||||
|
|
||||||
return sampleRate, totalSamples
|
return sampleRate, totalSamples, channels, bitDepth
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func TestGetFlacDuration_BasicParsing(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
ms, err := getFlacDuration(f)
|
ms, props, err := getFlacDuration(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getFlacDuration: %v", err)
|
t.Fatalf("getFlacDuration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,36 @@ func TestGetFlacDuration_BasicParsing(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("duration: %dms", ms)
|
if props == nil {
|
||||||
|
t.Fatal("expected non-nil AudioProperties")
|
||||||
|
}
|
||||||
|
|
||||||
|
if props.SampleRate <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected positive sample rate, got %d",
|
||||||
|
props.SampleRate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if props.BitDepth <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected positive bit depth, got %d",
|
||||||
|
props.BitDepth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if props.Channels <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected positive channels, got %d",
|
||||||
|
props.Channels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf(
|
||||||
|
"duration: %dms rate: %dHz depth: %d ch: %d",
|
||||||
|
ms, props.SampleRate, props.BitDepth,
|
||||||
|
props.Channels,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +115,7 @@ func TestGetFlacDuration_MatchesBeepDecode(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
fastMS, err := getFlacDuration(f)
|
fastMS, _, err := getFlacDuration(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getFlacDuration: %v", err)
|
t.Fatalf("getFlacDuration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +185,7 @@ func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = origF.Close() }()
|
defer func() { _ = origF.Close() }()
|
||||||
|
|
||||||
origMS, err := getFlacDuration(origF)
|
origMS, _, err := getFlacDuration(origF)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getFlacDuration on original: %v", err)
|
t.Fatalf("getFlacDuration on original: %v", err)
|
||||||
}
|
}
|
||||||
@@ -169,7 +198,7 @@ func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = tmpF.Close() }()
|
defer func() { _ = tmpF.Close() }()
|
||||||
|
|
||||||
wrappedMS, err := getFlacDuration(tmpF)
|
wrappedMS, _, err := getFlacDuration(tmpF)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"getFlacDuration on ID3v2-wrapped file: %v", err,
|
"getFlacDuration on ID3v2-wrapped file: %v", err,
|
||||||
@@ -222,12 +251,14 @@ func TestParseFlacStreamInfo(t *testing.T) {
|
|||||||
si[16] = 0x38
|
si[16] = 0x38
|
||||||
si[17] = 0x9E
|
si[17] = 0x9E
|
||||||
|
|
||||||
sr, total := parseFlacStreamInfo(si)
|
sr, total, ch, bps := parseFlacStreamInfo(si)
|
||||||
|
|
||||||
//nolint:mnd // expected test values.
|
//nolint:mnd // expected test values.
|
||||||
const (
|
const (
|
||||||
wantSR = 44100
|
wantSR = 44100
|
||||||
wantTotal = 11614366
|
wantTotal = 11614366
|
||||||
|
wantChannels = 2
|
||||||
|
wantBPS = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
if sr != wantSR {
|
if sr != wantSR {
|
||||||
@@ -240,6 +271,18 @@ func TestParseFlacStreamInfo(t *testing.T) {
|
|||||||
total, wantTotal,
|
total, wantTotal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ch != wantChannels {
|
||||||
|
t.Errorf(
|
||||||
|
"channels: got %d, want %d", ch, wantChannels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bps != wantBPS {
|
||||||
|
t.Errorf(
|
||||||
|
"bits per sample: got %d, want %d", bps, wantBPS,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildID3v2Header creates a minimal 10-byte ID3v2.3 header with
|
// buildID3v2Header creates a minimal 10-byte ID3v2.3 header with
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ type ExtractionTiming struct {
|
|||||||
DurationExtraction time.Duration
|
DurationExtraction time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AudioProperties holds technical properties of an audio file that
|
||||||
|
// are extracted from its stream headers during scanning.
|
||||||
|
type AudioProperties struct {
|
||||||
|
SampleRate int // Sample rate in Hz (e.g. 44100, 96000).
|
||||||
|
BitDepth int // Bits per sample (e.g. 16, 24).
|
||||||
|
Channels int // Number of audio channels (1=mono, 2=stereo).
|
||||||
|
Bitrate int // Bitrate in kbps.
|
||||||
|
FileSize int64 // File size in bytes.
|
||||||
|
}
|
||||||
|
|
||||||
// AudioFileExtension represents a supported audio file extension.
|
// AudioFileExtension represents a supported audio file extension.
|
||||||
type AudioFileExtension string
|
type AudioFileExtension string
|
||||||
|
|
||||||
@@ -60,25 +70,37 @@ func GetTrackLengthMillis(path string) (int64, error) {
|
|||||||
return lengthMillis, nil
|
return lengthMillis, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractAllMetadata opens the file once and extracts both tags and duration.
|
// ExtractAllMetadata opens the file once and extracts tags, duration,
|
||||||
// This avoids the overhead of opening the file twice when both are needed.
|
// and audio properties (sample rate, bit depth, channels, bitrate,
|
||||||
// If skipDuration is true, only tags are extracted and lengthMillis is 0.
|
// file size). If skipDuration is true, only tags are extracted and
|
||||||
|
// the remaining outputs are zero-valued.
|
||||||
// The returned ExtractionTiming records how long each sub-operation took.
|
// The returned ExtractionTiming records how long each sub-operation took.
|
||||||
func ExtractAllMetadata(
|
func ExtractAllMetadata(
|
||||||
path string,
|
path string,
|
||||||
skipDuration bool,
|
skipDuration bool,
|
||||||
) (*TrackMetadata, int64, *ExtractionTiming, error) {
|
) (*TrackMetadata, int64, *AudioProperties, *ExtractionTiming, error) {
|
||||||
timing := &ExtractionTiming{}
|
timing := &ExtractionTiming{}
|
||||||
|
props := &AudioProperties{}
|
||||||
|
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, timing, fmt.Errorf(
|
return nil, 0, props, timing, fmt.Errorf(
|
||||||
"could not open file: %w", err,
|
"could not open file: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
// Capture file size.
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, props, timing, fmt.Errorf(
|
||||||
|
"could not stat file: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
props.FileSize = fi.Size()
|
||||||
|
|
||||||
// Extract tags first (reads only headers, fast).
|
// Extract tags first (reads only headers, fast).
|
||||||
tagStart := time.Now()
|
tagStart := time.Now()
|
||||||
|
|
||||||
@@ -87,33 +109,45 @@ func ExtractAllMetadata(
|
|||||||
timing.TagExtraction = time.Since(tagStart)
|
timing.TagExtraction = time.Since(tagStart)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, timing, fmt.Errorf(
|
return nil, 0, props, timing, fmt.Errorf(
|
||||||
"could not extract tags from %s: %w", path, err,
|
"could not extract tags from %s: %w", path, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if skipDuration {
|
if skipDuration {
|
||||||
return tags, 0, timing, nil
|
return tags, 0, props, timing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek back to the beginning for duration extraction.
|
// Seek back to the beginning for duration extraction.
|
||||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
return tags, 0, timing, fmt.Errorf(
|
return tags, 0, props, timing, fmt.Errorf(
|
||||||
"could not seek file for duration: %w", err,
|
"could not seek file for duration: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
durStart := time.Now()
|
durStart := time.Now()
|
||||||
|
|
||||||
lengthMillis, err := getTrackDuration(f)
|
lengthMillis, audioProps, err := getTrackDuration(f)
|
||||||
|
|
||||||
timing.DurationExtraction = time.Since(durStart)
|
timing.DurationExtraction = time.Since(durStart)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tags, 0, timing, fmt.Errorf(
|
return tags, 0, props, timing, fmt.Errorf(
|
||||||
"error getting duration for %s: %w", path, err,
|
"error getting duration for %s: %w", path, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tags, lengthMillis, timing, nil
|
// Merge stream properties into the result, keeping the
|
||||||
|
// file size we already captured.
|
||||||
|
audioProps.FileSize = props.FileSize
|
||||||
|
|
||||||
|
// Compute bitrate from file size and duration when the
|
||||||
|
// format parser did not provide one (lossless formats).
|
||||||
|
if audioProps.Bitrate == 0 && lengthMillis > 0 {
|
||||||
|
audioProps.Bitrate = int(
|
||||||
|
props.FileSize * 8 / lengthMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, lengthMillis, audioProps, timing, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,23 +63,32 @@ func samplesPerFrame(version int) int {
|
|||||||
return 576 // MPEG2 / MPEG2.5
|
return 576 // MPEG2 / MPEG2.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mp3BitDepth is the effective bit depth for decoded MP3 audio.
|
||||||
|
// The MPEG standard decodes to 16-bit PCM.
|
||||||
|
const mp3BitDepth = 16
|
||||||
|
|
||||||
// getMP3Duration computes the duration of an MP3 file in
|
// getMP3Duration computes the duration of an MP3 file in
|
||||||
// milliseconds by reading only the first frame's header and any
|
// milliseconds by reading only the first frame's header and any
|
||||||
// Xing/VBRI VBR header it contains. For CBR files (no VBR header)
|
// Xing/VBRI VBR header it contains. For CBR files (no VBR header)
|
||||||
// it falls back to fileSize / bitrate.
|
// it falls back to fileSize / bitrate. It also returns audio
|
||||||
|
// properties extracted from the frame header.
|
||||||
//
|
//
|
||||||
// The file position is undefined after this call.
|
// The file position is undefined after this call.
|
||||||
func getMP3Duration(f *os.File) (int64, error) {
|
func getMP3Duration(
|
||||||
|
f *os.File,
|
||||||
|
) (int64, *AudioProperties, error) {
|
||||||
// 1. Skip all leading ID3v2 tags. Some files have multiple
|
// 1. Skip all leading ID3v2 tags. Some files have multiple
|
||||||
// consecutive tags from different tagging tools.
|
// consecutive tags from different tagging tools.
|
||||||
audioStart, err := skipID3v2(f)
|
audioStart, err := skipID3v2(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
return 0, nil, fmt.Errorf(
|
||||||
|
"skipping ID3v2: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
audioStart, err = skipAdditionalID3v2(f, audioStart)
|
audioStart, err = skipAdditionalID3v2(f, audioStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf(
|
return 0, nil, fmt.Errorf(
|
||||||
"skipping additional ID3v2 tags: %w", err,
|
"skipping additional ID3v2 tags: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,14 +96,29 @@ func getMP3Duration(f *os.File) (int64, error) {
|
|||||||
// 2. Find and parse the first MP3 frame header.
|
// 2. Find and parse the first MP3 frame header.
|
||||||
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build audio properties from the frame header.
|
||||||
|
channels := 2
|
||||||
|
if hdr.channelMode == 3 { //nolint:mnd // 3 = mono
|
||||||
|
channels = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
props := &AudioProperties{
|
||||||
|
SampleRate: hdr.sampleRate,
|
||||||
|
BitDepth: mp3BitDepth,
|
||||||
|
Channels: channels,
|
||||||
|
Bitrate: hdr.bitrateKbps,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Attempt to read a VBR header (Xing/Info or VBRI) from
|
// 3. Attempt to read a VBR header (Xing/Info or VBRI) from
|
||||||
// inside the first frame.
|
// inside the first frame.
|
||||||
vbrFrames, found, err := readVBRHeader(f, hdr, frameOffset)
|
vbrFrames, found, err := readVBRHeader(
|
||||||
|
f, hdr, frameOffset,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if found && vbrFrames > 0 {
|
if found && vbrFrames > 0 {
|
||||||
@@ -102,20 +126,22 @@ func getMP3Duration(f *os.File) (int64, error) {
|
|||||||
durationMS := int64(vbrFrames) *
|
durationMS := int64(vbrFrames) *
|
||||||
int64(spf) * 1000 / int64(hdr.sampleRate)
|
int64(spf) * 1000 / int64(hdr.sampleRate)
|
||||||
|
|
||||||
return durationMS, nil
|
return durationMS, props, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. CBR fallback: duration = audioBytes * 8 / bitrate.
|
// 4. CBR fallback: duration = audioBytes * 8 / bitrate.
|
||||||
fi, err := f.Stat()
|
fi, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("stat file for CBR duration: %w", err)
|
return 0, nil, fmt.Errorf(
|
||||||
|
"stat file for CBR duration: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
audioBytes := fi.Size() - audioStart
|
audioBytes := fi.Size() - audioStart
|
||||||
durationMS := audioBytes * 8 * 1000 /
|
durationMS := audioBytes * 8 * 1000 /
|
||||||
(int64(hdr.bitrateKbps) * 1000)
|
(int64(hdr.bitrateKbps) * 1000)
|
||||||
|
|
||||||
return durationMS, nil
|
return durationMS, props, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio
|
// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
fastMS, err := getMP3Duration(f)
|
fastMS, _, err := getMP3Duration(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"getMP3Duration failed: %v", err,
|
"getMP3Duration failed: %v", err,
|
||||||
@@ -106,7 +106,7 @@ func TestGetMP3Duration_BasicParsing(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
ms, err := getMP3Duration(f)
|
ms, _, err := getMP3Duration(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getMP3Duration: %v", err)
|
t.Fatalf("getMP3Duration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = origF.Close() }()
|
defer func() { _ = origF.Close() }()
|
||||||
|
|
||||||
origMS, err := getMP3Duration(origF)
|
origMS, _, err := getMP3Duration(origF)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getMP3Duration on original: %v", err)
|
t.Fatalf("getMP3Duration on original: %v", err)
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = tmpF.Close() }()
|
defer func() { _ = tmpF.Close() }()
|
||||||
|
|
||||||
wrappedMS, err := getMP3Duration(tmpF)
|
wrappedMS, _, err := getMP3Duration(tmpF)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"getMP3Duration on multi-ID3v2 file: %v", err,
|
"getMP3Duration on multi-ID3v2 file: %v", err,
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const (
|
|||||||
ColDiscNumber ColumnID = "discNumber"
|
ColDiscNumber ColumnID = "discNumber"
|
||||||
ColFilePath ColumnID = "filePath"
|
ColFilePath ColumnID = "filePath"
|
||||||
ColFileType ColumnID = "fileType"
|
ColFileType ColumnID = "fileType"
|
||||||
|
ColSampleRate ColumnID = "sampleRate"
|
||||||
|
ColBitDepth ColumnID = "bitDepth"
|
||||||
|
ColChannels ColumnID = "channels"
|
||||||
|
ColBitrate ColumnID = "bitrate"
|
||||||
|
ColFileSize ColumnID = "fileSize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllColumnIDs lists every recognised column in default display
|
// AllColumnIDs lists every recognised column in default display
|
||||||
@@ -44,6 +49,11 @@ var AllColumnIDs = []ColumnID{
|
|||||||
ColDiscNumber,
|
ColDiscNumber,
|
||||||
ColFilePath,
|
ColFilePath,
|
||||||
ColFileType,
|
ColFileType,
|
||||||
|
ColSampleRate,
|
||||||
|
ColBitDepth,
|
||||||
|
ColChannels,
|
||||||
|
ColBitrate,
|
||||||
|
ColFileSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultColumns is the initial column configuration matching the
|
// DefaultColumns is the initial column configuration matching the
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ export class ArtistsView extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private contextMenuOpen = false;
|
private contextMenuOpen = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artist ID that was right-clicked to open the
|
||||||
|
* context menu. Used as fallback when the
|
||||||
|
* right-clicked artist is not in the current
|
||||||
|
* visual selection.
|
||||||
|
*/
|
||||||
|
private contextMenuArtistId: number | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private playlistSubmenuOpen = false;
|
private playlistSubmenuOpen = false;
|
||||||
|
|
||||||
@@ -755,6 +763,40 @@ export class ArtistsView extends LitElement {
|
|||||||
return allPaths;
|
return allPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return file paths for the context menu target.
|
||||||
|
* If the right-clicked artist is part of the
|
||||||
|
* current selection, return paths for all selected
|
||||||
|
* artists. Otherwise return paths for the
|
||||||
|
* right-clicked artist only.
|
||||||
|
*/
|
||||||
|
private async getContextMenuArtistFilePaths(): Promise<
|
||||||
|
string[]
|
||||||
|
> {
|
||||||
|
if (
|
||||||
|
this.contextMenuArtistId !== null &&
|
||||||
|
!this.selectedArtists.has(
|
||||||
|
this.contextMenuArtistId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const artist = this.artists.find(
|
||||||
|
(a) =>
|
||||||
|
a.ID ===
|
||||||
|
this.contextMenuArtistId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (artist) {
|
||||||
|
return this.getArtistFilePaths(
|
||||||
|
artist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSelectedArtistFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear the current artist selection. */
|
/** Clear the current artist selection. */
|
||||||
private clearSelection() {
|
private clearSelection() {
|
||||||
this.selectedArtists = new Set();
|
this.selectedArtists = new Set();
|
||||||
@@ -831,21 +873,7 @@ export class ArtistsView extends LitElement {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// If right-clicked artist is not in the
|
this.contextMenuArtistId = artist.ID;
|
||||||
// current selection, replace the selection
|
|
||||||
// with just this artist.
|
|
||||||
if (
|
|
||||||
!this.selectedArtists.has(artist.ID)
|
|
||||||
) {
|
|
||||||
const idx =
|
|
||||||
this.filteredArtists.indexOf(artist);
|
|
||||||
|
|
||||||
this.selectedArtists = new Set([
|
|
||||||
artist.ID,
|
|
||||||
]);
|
|
||||||
this.lastSelectedArtistIndex =
|
|
||||||
idx >= 0 ? idx : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.openContextMenuAt(
|
this.openContextMenuAt(
|
||||||
e.clientX,
|
e.clientX,
|
||||||
@@ -888,6 +916,7 @@ export class ArtistsView extends LitElement {
|
|||||||
this.closePlaylistSubmenu();
|
this.closePlaylistSubmenu();
|
||||||
this.contextMenuOpen = false;
|
this.contextMenuOpen = false;
|
||||||
this.playlistFilePaths = [];
|
this.playlistFilePaths = [];
|
||||||
|
this.contextMenuArtistId = null;
|
||||||
|
|
||||||
const popup = this.contextMenuPopup;
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
@@ -899,10 +928,8 @@ export class ArtistsView extends LitElement {
|
|||||||
private async onContextMenuAction(
|
private async onContextMenuAction(
|
||||||
action: string,
|
action: string,
|
||||||
) {
|
) {
|
||||||
if (this.selectedArtists.size === 0) return;
|
|
||||||
|
|
||||||
const filePaths =
|
const filePaths =
|
||||||
await this.getSelectedArtistFilePaths();
|
await this.getContextMenuArtistFilePaths();
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
@@ -949,10 +976,12 @@ export class ArtistsView extends LitElement {
|
|||||||
|
|
||||||
if (this.playlistSubmenuOpen) return;
|
if (this.playlistSubmenuOpen) return;
|
||||||
|
|
||||||
if (this.selectedArtists.size === 0) return;
|
|
||||||
|
|
||||||
this.playlistFilePaths =
|
this.playlistFilePaths =
|
||||||
await this.getSelectedArtistFilePaths();
|
await this.getContextMenuArtistFilePaths();
|
||||||
|
|
||||||
|
if (this.playlistFilePaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.playlistSubmenuOpen = true;
|
this.playlistSubmenuOpen = true;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state } from 'lit/decorators.js';
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Scan, FullRescan } from '@go/library/Library';
|
import { Scan, FullRescan } from '@go/library/Library';
|
||||||
import {
|
import {
|
||||||
@@ -980,7 +981,7 @@ export class ConfigPage extends LitElement {
|
|||||||
description="Choose which columns are visible and set their display order."
|
description="Choose which columns are visible and set their display order."
|
||||||
>
|
>
|
||||||
<ul class="column-list">
|
<ul class="column-list">
|
||||||
${order.map((id, idx) => {
|
${repeat(order, (id) => id, (id, idx) => {
|
||||||
const checked =
|
const checked =
|
||||||
enabledIds.includes(id);
|
enabledIds.includes(id);
|
||||||
const onlyOne =
|
const onlyOne =
|
||||||
|
|||||||
@@ -646,6 +646,14 @@ export class CoverGrid extends LitElement {
|
|||||||
kind: 'album',
|
kind: 'album',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Album ID that was right-clicked to open the
|
||||||
|
* context menu. Used as fallback when the
|
||||||
|
* right-clicked album is not part of the current
|
||||||
|
* visual selection.
|
||||||
|
*/
|
||||||
|
private contextMenuAlbumId: number | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private selectedAlbums: Set<number> = new Set();
|
private selectedAlbums: Set<number> = new Set();
|
||||||
|
|
||||||
@@ -2371,6 +2379,40 @@ export class CoverGrid extends LitElement {
|
|||||||
return allPaths;
|
return allPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return file paths for the context menu target.
|
||||||
|
* If the right-clicked album is part of the current
|
||||||
|
* selection, return paths for all selected albums.
|
||||||
|
* Otherwise return paths for the right-clicked
|
||||||
|
* album only.
|
||||||
|
*/
|
||||||
|
private async getContextMenuAlbumFilePaths(): Promise<
|
||||||
|
string[]
|
||||||
|
> {
|
||||||
|
if (
|
||||||
|
this.contextMenuAlbumId !== null &&
|
||||||
|
!this.selectedAlbums.has(
|
||||||
|
this.contextMenuAlbumId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const album = this.albums.find(
|
||||||
|
(a) =>
|
||||||
|
a.ID ===
|
||||||
|
this.contextMenuAlbumId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (album) {
|
||||||
|
return this.getAlbumFilePaths(
|
||||||
|
album,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSelectedAlbumFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
private async getAlbumFilePaths(
|
private async getAlbumFilePaths(
|
||||||
album: library.Album,
|
album: library.Album,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
@@ -2704,14 +2746,7 @@ export class CoverGrid extends LitElement {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!this.selectedAlbums.has(hit.album.ID)) {
|
this.contextMenuAlbumId = hit.album.ID;
|
||||||
this.selectedAlbums = new Set([
|
|
||||||
hit.album.ID,
|
|
||||||
]);
|
|
||||||
this.syncDropdownToSelection();
|
|
||||||
void this.warmAlbumFilePathCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.contextMenuTarget = { kind: 'album' };
|
this.contextMenuTarget = { kind: 'album' };
|
||||||
this.openContextMenuAt(e.clientX, e.clientY);
|
this.openContextMenuAt(e.clientX, e.clientY);
|
||||||
};
|
};
|
||||||
@@ -3059,10 +3094,15 @@ export class CoverGrid extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async onContextMenuAction(action: string) {
|
private async onContextMenuAction(action: string) {
|
||||||
const filePaths =
|
let filePaths: string[];
|
||||||
this.contextMenuTarget.kind === 'track'
|
|
||||||
? this.getSelectedTrackFilePaths()
|
if (this.contextMenuTarget.kind === 'track') {
|
||||||
: await this.getSelectedAlbumFilePaths();
|
filePaths =
|
||||||
|
this.getSelectedTrackFilePaths();
|
||||||
|
} else {
|
||||||
|
filePaths =
|
||||||
|
await this.getContextMenuAlbumFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
@@ -3125,6 +3165,7 @@ export class CoverGrid extends LitElement {
|
|||||||
this.closePlaylistSubmenu();
|
this.closePlaylistSubmenu();
|
||||||
this.contextMenuOpen = false;
|
this.contextMenuOpen = false;
|
||||||
this.playlistFilePaths = [];
|
this.playlistFilePaths = [];
|
||||||
|
this.contextMenuAlbumId = null;
|
||||||
|
|
||||||
if (clearSelection) {
|
if (clearSelection) {
|
||||||
if (
|
if (
|
||||||
@@ -3166,9 +3207,9 @@ export class CoverGrid extends LitElement {
|
|||||||
if (this.contextMenuTarget.kind === 'track') {
|
if (this.contextMenuTarget.kind === 'track') {
|
||||||
this.playlistFilePaths =
|
this.playlistFilePaths =
|
||||||
this.getSelectedTrackFilePaths();
|
this.getSelectedTrackFilePaths();
|
||||||
} else if (this.selectedAlbums.size > 0) {
|
} else {
|
||||||
this.playlistFilePaths =
|
this.playlistFilePaths =
|
||||||
await this.getSelectedAlbumFilePaths();
|
await this.getContextMenuAlbumFilePaths();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.playlistSubmenuOpen = true;
|
this.playlistSubmenuOpen = true;
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ export class GenresView extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private contextMenuOpen = false;
|
private contextMenuOpen = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genre name that was right-clicked to open the
|
||||||
|
* context menu. Used as fallback when the
|
||||||
|
* right-clicked genre is not in the current
|
||||||
|
* visual selection.
|
||||||
|
*/
|
||||||
|
private contextMenuGenreName: string | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private playlistSubmenuOpen = false;
|
private playlistSubmenuOpen = false;
|
||||||
|
|
||||||
@@ -801,6 +809,45 @@ export class GenresView extends LitElement {
|
|||||||
return allPaths;
|
return allPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return file paths for the context menu target.
|
||||||
|
* If the right-clicked genre is part of the
|
||||||
|
* current selection, return paths for all selected
|
||||||
|
* genres. Otherwise return paths for the
|
||||||
|
* right-clicked genre only.
|
||||||
|
*/
|
||||||
|
private getContextMenuGenreFilePaths(): string[] {
|
||||||
|
if (
|
||||||
|
this.contextMenuGenreName !== null &&
|
||||||
|
!this.selectedGenres.has(
|
||||||
|
this.contextMenuGenreName,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const paths: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const track of this.allTracks) {
|
||||||
|
if (seen.has(track.FilePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const genres = track.Genre ?? [];
|
||||||
|
const match = genres.includes(
|
||||||
|
this.contextMenuGenreName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
paths.push(track.FilePath);
|
||||||
|
seen.add(track.FilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSelectedGenreFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear the current genre selection. */
|
/** Clear the current genre selection. */
|
||||||
private clearSelection() {
|
private clearSelection() {
|
||||||
this.selectedGenres = new Set();
|
this.selectedGenres = new Set();
|
||||||
@@ -876,19 +923,7 @@ export class GenresView extends LitElement {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// If right-clicked genre is not in the
|
this.contextMenuGenreName = genre.name;
|
||||||
// current selection, replace the selection
|
|
||||||
// with just this genre.
|
|
||||||
if (!this.selectedGenres.has(genre.name)) {
|
|
||||||
const idx =
|
|
||||||
this.filteredGenres.indexOf(genre);
|
|
||||||
|
|
||||||
this.selectedGenres = new Set([
|
|
||||||
genre.name,
|
|
||||||
]);
|
|
||||||
this.lastSelectedGenreIndex =
|
|
||||||
idx >= 0 ? idx : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.openContextMenuAt(
|
this.openContextMenuAt(
|
||||||
e.clientX,
|
e.clientX,
|
||||||
@@ -931,6 +966,7 @@ export class GenresView extends LitElement {
|
|||||||
this.closePlaylistSubmenu();
|
this.closePlaylistSubmenu();
|
||||||
this.contextMenuOpen = false;
|
this.contextMenuOpen = false;
|
||||||
this.playlistFilePaths = [];
|
this.playlistFilePaths = [];
|
||||||
|
this.contextMenuGenreName = null;
|
||||||
|
|
||||||
const popup = this.contextMenuPopup;
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
@@ -940,10 +976,8 @@ export class GenresView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onContextMenuAction(action: string) {
|
private onContextMenuAction(action: string) {
|
||||||
if (this.selectedGenres.size === 0) return;
|
|
||||||
|
|
||||||
const filePaths =
|
const filePaths =
|
||||||
this.getSelectedGenreFilePaths();
|
this.getContextMenuGenreFilePaths();
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
@@ -990,10 +1024,12 @@ export class GenresView extends LitElement {
|
|||||||
|
|
||||||
if (this.playlistSubmenuOpen) return;
|
if (this.playlistSubmenuOpen) return;
|
||||||
|
|
||||||
if (this.selectedGenres.size === 0) return;
|
|
||||||
|
|
||||||
this.playlistFilePaths =
|
this.playlistFilePaths =
|
||||||
this.getSelectedGenreFilePaths();
|
this.getContextMenuGenreFilePaths();
|
||||||
|
|
||||||
|
if (this.playlistFilePaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.playlistSubmenuOpen = true;
|
this.playlistSubmenuOpen = true;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import {
|
|||||||
query,
|
query,
|
||||||
} from 'lit/decorators.js';
|
} from 'lit/decorators.js';
|
||||||
import type { library } from '@go/models';
|
import type { library } from '@go/models';
|
||||||
|
import {
|
||||||
|
formatSampleRate,
|
||||||
|
formatBitDepth,
|
||||||
|
formatChannels,
|
||||||
|
formatBitrate,
|
||||||
|
formatFileSize,
|
||||||
|
} from '@utils/format';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
import { formatMilliseconds } from '@utils/time';
|
||||||
|
|
||||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||||
@@ -182,6 +189,15 @@ export class TrackDetails extends LitElement {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.metadata-grid {
|
.metadata-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px 1fr;
|
grid-template-columns: 120px 1fr;
|
||||||
@@ -330,6 +346,13 @@ export class TrackDetails extends LitElement {
|
|||||||
<div class="metadata-grid">
|
<div class="metadata-grid">
|
||||||
${this.renderDetailFields(t)}
|
${this.renderDetailFields(t)}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="section-label">
|
||||||
|
Audio Properties
|
||||||
|
</div>
|
||||||
|
<div class="metadata-grid">
|
||||||
|
${this.renderAudioProperties(t)}
|
||||||
|
</div>
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
${this.renderActions()}
|
${this.renderActions()}
|
||||||
</div>
|
</div>
|
||||||
@@ -511,6 +534,45 @@ export class TrackDetails extends LitElement {
|
|||||||
return fields.map((f) => this.renderField(f));
|
return fields.map((f) => this.renderField(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderAudioProperties(t: library.Track) {
|
||||||
|
const props: { label: string; value: string }[] = [
|
||||||
|
{
|
||||||
|
label: 'Sample Rate',
|
||||||
|
value: formatSampleRate(t.SampleRate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bit Depth',
|
||||||
|
value: formatBitDepth(t.BitDepth),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Channels',
|
||||||
|
value: formatChannels(t.Channels),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bitrate',
|
||||||
|
value: formatBitrate(t.Bitrate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'File Size',
|
||||||
|
value: formatFileSize(t.FileSize),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return props.map(
|
||||||
|
(p) => html`
|
||||||
|
<span class="meta-label">${p.label}</span>
|
||||||
|
<span
|
||||||
|
class="meta-value ${p.value ===
|
||||||
|
'\u2014'
|
||||||
|
? 'empty'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
${p.value}
|
||||||
|
</span>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private renderField(f: MetadataField) {
|
private renderField(f: MetadataField) {
|
||||||
const display =
|
const display =
|
||||||
this.getEditValue(f.key, f.value) || f.value;
|
this.getEditValue(f.key, f.value) || f.value;
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import type { library } from '@go/models';
|
import type { library } from '@go/models';
|
||||||
|
import {
|
||||||
|
formatSampleRate,
|
||||||
|
formatBitDepth,
|
||||||
|
formatChannels,
|
||||||
|
formatBitrate,
|
||||||
|
formatFileSize,
|
||||||
|
} from '@utils/format';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
import { formatMilliseconds } from '@utils/time';
|
||||||
|
|
||||||
/** Compares two strings using locale-aware ordering. */
|
/** Compares two strings using locale-aware ordering. */
|
||||||
@@ -135,6 +142,50 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
|||||||
comparator: (a, b) =>
|
comparator: (a, b) =>
|
||||||
compareStr(a.FileType, b.FileType),
|
compareStr(a.FileType, b.FileType),
|
||||||
},
|
},
|
||||||
|
sampleRate: {
|
||||||
|
id: 'sampleRate',
|
||||||
|
label: 'Sample Rate',
|
||||||
|
accessor: (t) => formatSampleRate(t.SampleRate),
|
||||||
|
defaultWidth: '100px',
|
||||||
|
align: 'right',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.SampleRate, b.SampleRate),
|
||||||
|
},
|
||||||
|
bitDepth: {
|
||||||
|
id: 'bitDepth',
|
||||||
|
label: 'Bit Depth',
|
||||||
|
accessor: (t) => formatBitDepth(t.BitDepth),
|
||||||
|
defaultWidth: '80px',
|
||||||
|
align: 'right',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.BitDepth, b.BitDepth),
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
id: 'channels',
|
||||||
|
label: 'Channels',
|
||||||
|
accessor: (t) => formatChannels(t.Channels),
|
||||||
|
defaultWidth: '80px',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.Channels, b.Channels),
|
||||||
|
},
|
||||||
|
bitrate: {
|
||||||
|
id: 'bitrate',
|
||||||
|
label: 'Bitrate',
|
||||||
|
accessor: (t) => formatBitrate(t.Bitrate),
|
||||||
|
defaultWidth: '100px',
|
||||||
|
align: 'right',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.Bitrate, b.Bitrate),
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
id: 'fileSize',
|
||||||
|
label: 'File Size',
|
||||||
|
accessor: (t) => formatFileSize(t.FileSize),
|
||||||
|
defaultWidth: '80px',
|
||||||
|
align: 'right',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.FileSize, b.FileSize),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -894,6 +894,10 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
#context-menu {
|
#context-menu {
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
@@ -1540,13 +1544,21 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
this.onTrackDragStart(e, track)}
|
this.onTrackDragStart(e, track)}
|
||||||
@dragend=${this.onTrackDragEnd}
|
@dragend=${this.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
${cols.map(
|
${cols.map((col) => {
|
||||||
(col) => html`
|
const val = col.accessor(track);
|
||||||
<div class="cell ${col.align === 'right' ? 'cell-right' : ''}">
|
const centered = val === '\u2014';
|
||||||
${col.accessor(track)}
|
const align = centered
|
||||||
</div>
|
? 'cell-center'
|
||||||
`,
|
: col.align === 'right'
|
||||||
)}
|
? 'cell-right'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="cell ${align}">
|
||||||
|
${val}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/** Em-dash used for unknown/zero values. */
|
||||||
|
const UNKNOWN = '\u2014';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a sample rate in Hz to a human-readable string.
|
||||||
|
* Returns "44.1 kHz", "48 kHz", "96 kHz", etc.
|
||||||
|
* Returns an em-dash for zero or falsy values.
|
||||||
|
*/
|
||||||
|
export function formatSampleRate(hz: number): string {
|
||||||
|
if (!hz) return UNKNOWN;
|
||||||
|
|
||||||
|
const khz = hz / 1000;
|
||||||
|
|
||||||
|
// Display as integer if it's a whole number, otherwise
|
||||||
|
// one decimal place (e.g. 44.1 kHz).
|
||||||
|
const formatted =
|
||||||
|
khz % 1 === 0 ? khz.toString() : khz.toFixed(1);
|
||||||
|
|
||||||
|
return `${formatted} kHz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bit depth (bits per sample) to a human-readable string.
|
||||||
|
* Returns "16-bit", "24-bit", "32-bit", etc.
|
||||||
|
* Returns an em-dash for zero or falsy values.
|
||||||
|
*/
|
||||||
|
export function formatBitDepth(bits: number): string {
|
||||||
|
if (!bits) return UNKNOWN;
|
||||||
|
|
||||||
|
return `${bits}-bit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a channel count to a human-readable string.
|
||||||
|
* Returns "Mono", "Stereo", or "N ch" for other counts.
|
||||||
|
* Returns an em-dash for zero or falsy values.
|
||||||
|
*/
|
||||||
|
export function formatChannels(n: number): string {
|
||||||
|
if (!n) return UNKNOWN;
|
||||||
|
if (n === 1) return 'Mono';
|
||||||
|
if (n === 2) return 'Stereo';
|
||||||
|
|
||||||
|
return `${n} ch`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a bitrate in kbps to a human-readable string.
|
||||||
|
* Returns "320 kbps", "1,411 kbps", etc.
|
||||||
|
* Returns an em-dash for zero or falsy values.
|
||||||
|
*/
|
||||||
|
export function formatBitrate(kbps: number): string {
|
||||||
|
if (!kbps) return UNKNOWN;
|
||||||
|
|
||||||
|
return `${kbps.toLocaleString()} kbps`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a file size in bytes to a human-readable string.
|
||||||
|
* Uses binary units: KiB, MiB, GiB.
|
||||||
|
* Returns an em-dash for zero or falsy values.
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (!bytes) return UNKNOWN;
|
||||||
|
|
||||||
|
const kib = 1024;
|
||||||
|
const mib = kib * 1024;
|
||||||
|
const gib = mib * 1024;
|
||||||
|
|
||||||
|
if (bytes >= gib) {
|
||||||
|
return `${(bytes / gib).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes >= mib) {
|
||||||
|
return `${(bytes / mib).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes >= kib) {
|
||||||
|
return `${(bytes / kib).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
@@ -112,6 +112,11 @@ export namespace library {
|
|||||||
Year: number;
|
Year: number;
|
||||||
Composer: string;
|
Composer: string;
|
||||||
FileType: string;
|
FileType: string;
|
||||||
|
SampleRate: number;
|
||||||
|
BitDepth: number;
|
||||||
|
Channels: number;
|
||||||
|
Bitrate: number;
|
||||||
|
FileSize: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Track(source);
|
return new Track(source);
|
||||||
@@ -130,6 +135,11 @@ export namespace library {
|
|||||||
this.Year = source["Year"];
|
this.Year = source["Year"];
|
||||||
this.Composer = source["Composer"];
|
this.Composer = source["Composer"];
|
||||||
this.FileType = source["FileType"];
|
this.FileType = source["FileType"];
|
||||||
|
this.SampleRate = source["SampleRate"];
|
||||||
|
this.BitDepth = source["BitDepth"];
|
||||||
|
this.Channels = source["Channels"];
|
||||||
|
this.Bitrate = source["Bitrate"];
|
||||||
|
this.FileSize = source["FileSize"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user