diff --git a/backend/database/database.go b/backend/database/database.go index 6ce9a33..a3733e7 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -9,6 +9,7 @@ import ( "io/fs" "log/slog" "path" + "strings" _ "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 // that ran without foreign key enforcement. 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) { 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", + ) +} diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index 885a049..ce7f073 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -1,5 +1,5 @@ -- 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 *; -- name: GetAudioFile :one @@ -12,12 +12,12 @@ WHERE file_path = ? LIMIT 1; -- name: UpdateAudioFile :exec 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 = ?; -- name: UpdateAudioFileRecording :exec UPDATE audio_files -SET recording_id = ? +SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ? WHERE id = ?; -- name: DeleteAudioFile :exec @@ -89,7 +89,12 @@ SELECT ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, 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 JOIN recordings r ON af.recording_id = r.id JOIN artist_credit ac ON r.artist_credit_id = ac.id diff --git a/backend/database/sql/schemas/audio_files.sql b/backend/database/sql/schemas/audio_files.sql index 4cbb128..19c205c 100644 --- a/backend/database/sql/schemas/audio_files.sql +++ b/backend/database/sql/schemas/audio_files.sql @@ -4,6 +4,11 @@ CREATE TABLE IF NOT EXISTS audio_files ( length_milliseconds int NOT NULL, file_type_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(recording_id) REFERENCES recordings(id) ); diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index f758d26..bc37221 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -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) VALUES (?, ?, ?, ?) -RETURNING id, file_path, length_milliseconds, file_type_id, recording_id +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 ` type CreateAudioFileParams struct { @@ -31,6 +31,11 @@ type CreateAudioFileParams struct { LengthMilliseconds int64 FileTypeID int64 RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 } 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.FileTypeID, arg.RecordingID, + arg.SampleRate, + arg.BitDepth, + arg.Channels, + arg.Bitrate, + arg.FileSize, ) var i AudioFile err := row.Scan( @@ -47,6 +57,11 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, ) return i, err } @@ -103,7 +118,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 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) { @@ -121,6 +136,11 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) { &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, ); err != nil { return nil, err } @@ -208,7 +228,12 @@ SELECT ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, 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 JOIN recordings r ON af.recording_id = r.id JOIN artist_credit ac ON r.artist_credit_id = ac.id @@ -229,6 +254,11 @@ type GetAllTracksWithFullMetadataRow struct { Year int64 Composer string FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 } func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) { @@ -252,6 +282,11 @@ func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTra &i.Year, &i.Composer, &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, ); err != nil { return nil, err } @@ -267,7 +302,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 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 ` @@ -280,12 +315,17 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error) &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, ) return i, err } 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 ` @@ -298,6 +338,11 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, ) return i, err } @@ -358,7 +403,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 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 ` @@ -377,6 +422,11 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, ); err != nil { return nil, err } @@ -444,7 +494,7 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) ( const updateAudioFile = `-- name: UpdateAudioFile :exec 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 = ? ` @@ -453,6 +503,11 @@ type UpdateAudioFileParams struct { LengthMilliseconds int64 FileTypeID int64 RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 ID int64 } @@ -462,6 +517,11 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams arg.LengthMilliseconds, arg.FileTypeID, arg.RecordingID, + arg.SampleRate, + arg.BitDepth, + arg.Channels, + arg.Bitrate, + arg.FileSize, arg.ID, ) return err @@ -469,16 +529,29 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec UPDATE audio_files -SET recording_id = ? +SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ? WHERE id = ? ` type UpdateAudioFileRecordingParams struct { RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 ID int64 } 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 } diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index c9ad29f..4fe671e 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -31,6 +31,11 @@ type AudioFile struct { LengthMilliseconds int64 FileTypeID int64 RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 } type CoverArt struct { diff --git a/backend/library/library.go b/backend/library/library.go index 228dd98..7d728b1 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -576,6 +576,7 @@ type importResult struct { fileType metadata.AudioFileExtension lengthMillis int64 tags *metadata.TrackMetadata + audioProps *metadata.AudioProperties existingFileID int64 // non-zero if this is an update needsUpdate bool } @@ -597,7 +598,7 @@ func (l *Library) extractAudioMetadata( // Skip duration decode if we already have it from a previous import. skipDuration := work.needsUpdate && work.existingLength > 0 - tags, lengthMillis, timing, err := metadata.ExtractAllMetadata( + tags, lengthMillis, audioProps, timing, err := metadata.ExtractAllMetadata( work.absolutePath, skipDuration, ) @@ -618,6 +619,7 @@ func (l *Library) extractAudioMetadata( } result.tags = tags + result.audioProps = audioProps if skipDuration { result.lengthMillis = work.existingLength @@ -721,6 +723,11 @@ func (l *Library) saveAudioFile( return fmt.Errorf("could not process metadata: %w", err) } + props := result.audioProps + if props == nil { + props = &metadata.AudioProperties{} + } + if _, err := q.CreateAudioFile( l.ctx, sqlcgen.CreateAudioFileParams{ FilePath: result.absolutePath, @@ -732,6 +739,11 @@ func (l *Library) saveAudioFile( ), ), RecordingID: recordingID, + SampleRate: int64(props.SampleRate), + BitDepth: int64(props.BitDepth), + Channels: int64(props.Channels), + Bitrate: int64(props.Bitrate), + FileSize: props.FileSize, }); err != nil { return fmt.Errorf( "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) } + props := result.audioProps + if props == nil { + props = &metadata.AudioProperties{} + } + if err := q.UpdateAudioFileRecording( l.ctx, sqlcgen.UpdateAudioFileRecordingParams{ RecordingID: recordingID, + SampleRate: int64(props.SampleRate), + BitDepth: int64(props.BitDepth), + Channels: int64(props.Channels), + Bitrate: int64(props.Bitrate), + FileSize: props.FileSize, ID: result.existingFileID, }); err != nil { return fmt.Errorf( diff --git a/backend/library/query.go b/backend/library/query.go index edaf6cf..0bf93a0 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -27,6 +27,11 @@ type Track struct { Year int64 Composer string FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 } // genreDelimiter is the separator used by GROUP_CONCAT in the @@ -100,6 +105,11 @@ func (l *Library) GetAllTracks() ([]Track, error) { Year: row.Year, Composer: row.Composer, FileType: row.FileType, + SampleRate: row.SampleRate, + BitDepth: row.BitDepth, + Channels: row.Channels, + Bitrate: row.Bitrate, + FileSize: row.FileSize, } tracks = append(tracks, track) diff --git a/backend/metadata/duration.go b/backend/metadata/duration.go index 2393c73..ceee57f 100644 --- a/backend/metadata/duration.go +++ b/backend/metadata/duration.go @@ -7,12 +7,15 @@ import ( ) // getTrackDuration returns the duration of an audio file in -// milliseconds. For MP3 files it uses a fast header-only parser -// (Xing/VBRI/CBR); for other formats it falls back to a full -// decode via beep which is already O(1) for FLAC, OGG, and WAV. +// milliseconds together with its audio stream properties. For MP3 +// files it uses a fast header-only parser (Xing/VBRI/CBR); for FLAC +// 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. -func getTrackDuration(f *os.File) (int64, error) { +func getTrackDuration( + f *os.File, +) (int64, *AudioProperties, error) { ext := filepath.Ext(f.Name()) switch ext { @@ -26,7 +29,9 @@ func getTrackDuration(f *os.File) (int64, error) { // (reads headers/metadata only, no full audio decode). streamer, format, err := DecodeFile(f) if err != nil { - return 0, fmt.Errorf("error decoding file: %w", err) + return 0, nil, fmt.Errorf( + "error decoding file: %w", err, + ) } lengthMillis := int64( @@ -35,5 +40,11 @@ func getTrackDuration(f *os.File) (int64, error) { ) _ = streamer.Close() - return lengthMillis, nil + props := &AudioProperties{ + SampleRate: int(format.SampleRate), + BitDepth: format.Precision * 8, + Channels: format.NumChannels, + } + + return lengthMillis, props, nil } diff --git a/backend/metadata/flacduration.go b/backend/metadata/flacduration.go index 6aafbab..63d479b 100644 --- a/backend/metadata/flacduration.go +++ b/backend/metadata/flacduration.go @@ -38,7 +38,9 @@ const streamInfoBlockType = 0 // getFlacDuration computes the duration of a FLAC file in // 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 // 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. // //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) 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. var sig [4]byte if _, err := f.ReadAt(sig[:], audioStart); err != nil { - return 0, fmt.Errorf( + return 0, nil, fmt.Errorf( "reading FLAC signature: %w", err, ) } if sig != flacSignatureBytes { - return 0, fmt.Errorf( + return 0, nil, fmt.Errorf( "%w: expected %q, got %q", errInvalidFLACSignature, flacSignatureBytes, sig, ) @@ -76,7 +80,7 @@ func getFlacDuration(f *os.File) (int64, error) { if _, err := f.ReadAt( mbh[:], audioStart+4, ); err != nil { - return 0, fmt.Errorf( + return 0, nil, fmt.Errorf( "reading metadata block header: %w", err, ) } @@ -89,7 +93,7 @@ func getFlacDuration(f *os.File) (int64, error) { if blockType != streamInfoBlockType || blockLen != streamInfoLength { - return 0, fmt.Errorf( + return 0, nil, fmt.Errorf( "%w: type=%d, length=%d", errInvalidStreamInfo, blockType, blockLen, ) @@ -101,41 +105,50 @@ func getFlacDuration(f *os.File) (int64, error) { if _, err := f.ReadAt( si[:], audioStart+8, ); err != nil { - return 0, fmt.Errorf( + return 0, nil, fmt.Errorf( "reading StreamInfo block: %w", err, ) } - sampleRate, totalSamples := parseFlacStreamInfo(si) + sampleRate, totalSamples, channels, bitDepth := parseFlacStreamInfo(si) if sampleRate == 0 { - return 0, errZeroSampleRate + return 0, nil, errZeroSampleRate } durationMS := int64(totalSamples) * 1000 / 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 -// sample count (36 bits) from a 34-byte FLAC StreamInfo body. +// parseFlacStreamInfo extracts key fields from a 34-byte FLAC +// StreamInfo body. // // StreamInfo layout (bytes 10-17 contain the fields we need): // // bits 0-19: sample rate in Hz (20 bits) -// bits 20-22: number of channels -1 (3 bits, unused here) -// bits 23-27: bits per sample -1 (5 bits, unused here) +// bits 20-22: number of channels -1 (3 bits) +// bits 23-27: bits per sample -1 (5 bits) // bits 28-63: total samples (36 bits) // //nolint:mnd // bit offsets from the FLAC spec. func parseFlacStreamInfo( 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 - // 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]) 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. totalSamples = uint64(si[13]&0x0F)<<32 | @@ -144,5 +157,5 @@ func parseFlacStreamInfo( uint64(si[16])<<8 | uint64(si[17]) - return sampleRate, totalSamples + return sampleRate, totalSamples, channels, bitDepth } diff --git a/backend/metadata/flacduration_test.go b/backend/metadata/flacduration_test.go index f6529e5..e7eae29 100644 --- a/backend/metadata/flacduration_test.go +++ b/backend/metadata/flacduration_test.go @@ -51,7 +51,7 @@ func TestGetFlacDuration_BasicParsing(t *testing.T) { defer func() { _ = f.Close() }() - ms, err := getFlacDuration(f) + ms, props, err := getFlacDuration(f) if err != nil { 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() }() - fastMS, err := getFlacDuration(f) + fastMS, _, err := getFlacDuration(f) if err != nil { t.Fatalf("getFlacDuration: %v", err) } @@ -156,7 +185,7 @@ func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) { defer func() { _ = origF.Close() }() - origMS, err := getFlacDuration(origF) + origMS, _, err := getFlacDuration(origF) if err != nil { t.Fatalf("getFlacDuration on original: %v", err) } @@ -169,7 +198,7 @@ func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) { defer func() { _ = tmpF.Close() }() - wrappedMS, err := getFlacDuration(tmpF) + wrappedMS, _, err := getFlacDuration(tmpF) if err != nil { t.Fatalf( "getFlacDuration on ID3v2-wrapped file: %v", err, @@ -222,12 +251,14 @@ func TestParseFlacStreamInfo(t *testing.T) { si[16] = 0x38 si[17] = 0x9E - sr, total := parseFlacStreamInfo(si) + sr, total, ch, bps := parseFlacStreamInfo(si) //nolint:mnd // expected test values. const ( - wantSR = 44100 - wantTotal = 11614366 + wantSR = 44100 + wantTotal = 11614366 + wantChannels = 2 + wantBPS = 16 ) if sr != wantSR { @@ -240,6 +271,18 @@ func TestParseFlacStreamInfo(t *testing.T) { 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 diff --git a/backend/metadata/metadata.go b/backend/metadata/metadata.go index b5b1de2..175b0d5 100644 --- a/backend/metadata/metadata.go +++ b/backend/metadata/metadata.go @@ -14,6 +14,16 @@ type ExtractionTiming struct { 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. type AudioFileExtension string @@ -60,25 +70,37 @@ func GetTrackLengthMillis(path string) (int64, error) { return lengthMillis, nil } -// ExtractAllMetadata opens the file once and extracts both tags and duration. -// This avoids the overhead of opening the file twice when both are needed. -// If skipDuration is true, only tags are extracted and lengthMillis is 0. +// ExtractAllMetadata opens the file once and extracts tags, duration, +// and audio properties (sample rate, bit depth, channels, bitrate, +// 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. func ExtractAllMetadata( path string, skipDuration bool, -) (*TrackMetadata, int64, *ExtractionTiming, error) { +) (*TrackMetadata, int64, *AudioProperties, *ExtractionTiming, error) { timing := &ExtractionTiming{} + props := &AudioProperties{} f, err := os.Open(path) if err != nil { - return nil, 0, timing, fmt.Errorf( + return nil, 0, props, timing, fmt.Errorf( "could not open file: %w", err, ) } 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). tagStart := time.Now() @@ -87,33 +109,45 @@ func ExtractAllMetadata( timing.TagExtraction = time.Since(tagStart) 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, ) } if skipDuration { - return tags, 0, timing, nil + return tags, 0, props, timing, nil } // Seek back to the beginning for duration extraction. 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, ) } durStart := time.Now() - lengthMillis, err := getTrackDuration(f) + lengthMillis, audioProps, err := getTrackDuration(f) timing.DurationExtraction = time.Since(durStart) if err != nil { - return tags, 0, timing, fmt.Errorf( + return tags, 0, props, timing, fmt.Errorf( "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 } diff --git a/backend/metadata/mp3duration.go b/backend/metadata/mp3duration.go index 218e4ae..be33da0 100644 --- a/backend/metadata/mp3duration.go +++ b/backend/metadata/mp3duration.go @@ -63,23 +63,32 @@ func samplesPerFrame(version int) int { 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 // milliseconds by reading only the first frame's header and any // 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. -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 // consecutive tags from different tagging tools. audioStart, err := skipID3v2(f) 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) if err != nil { - return 0, fmt.Errorf( + return 0, nil, fmt.Errorf( "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. hdr, frameOffset, err := findFrameHeader(f, audioStart) 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 // inside the first frame. - vbrFrames, found, err := readVBRHeader(f, hdr, frameOffset) + vbrFrames, found, err := readVBRHeader( + f, hdr, frameOffset, + ) if err != nil { - return 0, err + return 0, nil, err } if found && vbrFrames > 0 { @@ -102,20 +126,22 @@ func getMP3Duration(f *os.File) (int64, error) { durationMS := int64(vbrFrames) * int64(spf) * 1000 / int64(hdr.sampleRate) - return durationMS, nil + return durationMS, props, nil } // 4. CBR fallback: duration = audioBytes * 8 / bitrate. fi, err := f.Stat() 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 durationMS := audioBytes * 8 * 1000 / (int64(hdr.bitrateKbps) * 1000) - return durationMS, nil + return durationMS, props, nil } // mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio diff --git a/backend/metadata/mp3duration_test.go b/backend/metadata/mp3duration_test.go index dec83b8..f44c055 100644 --- a/backend/metadata/mp3duration_test.go +++ b/backend/metadata/mp3duration_test.go @@ -61,7 +61,7 @@ func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) { defer func() { _ = f.Close() }() - fastMS, err := getMP3Duration(f) + fastMS, _, err := getMP3Duration(f) if err != nil { t.Fatalf( "getMP3Duration failed: %v", err, @@ -106,7 +106,7 @@ func TestGetMP3Duration_BasicParsing(t *testing.T) { defer func() { _ = f.Close() }() - ms, err := getMP3Duration(f) + ms, _, err := getMP3Duration(f) if err != nil { t.Fatalf("getMP3Duration: %v", err) } @@ -136,7 +136,7 @@ func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) { defer func() { _ = origF.Close() }() - origMS, err := getMP3Duration(origF) + origMS, _, err := getMP3Duration(origF) if err != nil { t.Fatalf("getMP3Duration on original: %v", err) } @@ -176,7 +176,7 @@ func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) { defer func() { _ = tmpF.Close() }() - wrappedMS, err := getMP3Duration(tmpF) + wrappedMS, _, err := getMP3Duration(tmpF) if err != nil { t.Fatalf( "getMP3Duration on multi-ID3v2 file: %v", err, diff --git a/backend/tracklist/config.go b/backend/tracklist/config.go index cee0d09..e6ae53a 100644 --- a/backend/tracklist/config.go +++ b/backend/tracklist/config.go @@ -28,6 +28,11 @@ const ( ColDiscNumber ColumnID = "discNumber" ColFilePath ColumnID = "filePath" 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 @@ -44,6 +49,11 @@ var AllColumnIDs = []ColumnID{ ColDiscNumber, ColFilePath, ColFileType, + ColSampleRate, + ColBitDepth, + ColChannels, + ColBitrate, + ColFileSize, } // DefaultColumns is the initial column configuration matching the diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index a3cfd24..0de1dba 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -84,6 +84,14 @@ export class ArtistsView extends LitElement { @state() 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() private playlistSubmenuOpen = false; @@ -755,6 +763,40 @@ export class ArtistsView extends LitElement { 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. */ private clearSelection() { this.selectedArtists = new Set(); @@ -831,21 +873,7 @@ export class ArtistsView extends LitElement { e.preventDefault(); e.stopPropagation(); - // If right-clicked artist is not in the - // 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.contextMenuArtistId = artist.ID; this.openContextMenuAt( e.clientX, @@ -888,6 +916,7 @@ export class ArtistsView extends LitElement { this.closePlaylistSubmenu(); this.contextMenuOpen = false; this.playlistFilePaths = []; + this.contextMenuArtistId = null; const popup = this.contextMenuPopup; @@ -899,10 +928,8 @@ export class ArtistsView extends LitElement { private async onContextMenuAction( action: string, ) { - if (this.selectedArtists.size === 0) return; - const filePaths = - await this.getSelectedArtistFilePaths(); + await this.getContextMenuArtistFilePaths(); if (filePaths.length === 0) return; @@ -949,10 +976,12 @@ export class ArtistsView extends LitElement { if (this.playlistSubmenuOpen) return; - if (this.selectedArtists.size === 0) return; - this.playlistFilePaths = - await this.getSelectedArtistFilePaths(); + await this.getContextMenuArtistFilePaths(); + + if (this.playlistFilePaths.length === 0) { + return; + } this.playlistSubmenuOpen = true; diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index 1c4d68d..ddb5d71 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -1,5 +1,6 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; import { EventsOn } from '@runtime/runtime'; import { Scan, FullRescan } from '@go/library/Library'; import { @@ -980,7 +981,7 @@ export class ConfigPage extends LitElement { description="Choose which columns are visible and set their display order." >