audio file info added, fixed right click selecting.

This commit is contained in:
2026-02-23 19:05:57 -05:00
parent a6b476399e
commit 16060023bb
23 changed files with 798 additions and 134 deletions
+83
View File
@@ -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",
)
}
+9 -4
View File
@@ -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
@@ -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)
);
+83 -10
View File
@@ -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
}
+5
View File
@@ -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 {
+23 -1
View File
@@ -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(
+10
View File
@@ -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)
+17 -6
View File
@@ -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
}
+31 -18
View File
@@ -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
}
+51 -8
View File
@@ -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
+45 -11
View File
@@ -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
}
+36 -10
View File
@@ -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
+4 -4
View File
@@ -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,
+10
View File
@@ -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