From 252075ee1c3b7a298f6539701c50a4c9d07c1c08 Mon Sep 17 00:00:00 2001 From: benya Date: Fri, 3 Apr 2026 02:34:07 +0300 Subject: [PATCH] feat: extract track duration and bitrate during scans --- apps/web/src/lib/api.ts | 1 + apps/web/src/pages/album-detail-page.tsx | 9 +- go.mod | 5 + go.sum | 13 ++ internal/db/migrations/0004_track_bitrate.sql | 1 + internal/library/service.go | 16 ++- internal/scanner/service.go | 135 +++++++++++++++++- 7 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 internal/db/migrations/0004_track_bitrate.sql diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 66c747b..2183983 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -32,6 +32,7 @@ export type Track = { albumTitle: string trackNumber: number durationSeconds: number + bitrateKbps?: number contentType?: string coverArtId?: string playCount?: number diff --git a/apps/web/src/pages/album-detail-page.tsx b/apps/web/src/pages/album-detail-page.tsx index 64175d3..6c8b2bd 100644 --- a/apps/web/src/pages/album-detail-page.tsx +++ b/apps/web/src/pages/album-detail-page.tsx @@ -103,7 +103,7 @@ export function AlbumDetailPage() {
{formatDuration(track.durationSeconds)}
{track.playCount ?? 0}
{formatLastPlayed(track.lastPlayedAt)}
-
+
{formatBitrate(track.bitrateKbps)}
{formatQuality(track)}
@@ -187,3 +187,10 @@ function formatQuality(track: Track) { } return 'AUDIO' } + +function formatBitrate(value?: number) { + if (!value) { + return '—' + } + return `${value} kbps` +} diff --git a/go.mod b/go.mod index 470f19c..2e03f19 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,12 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + github.com/icza/bitio v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mewkiz/flac v1.0.13 // indirect + github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect + github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect diff --git a/go.sum b/go.sum index df6c1f9..383e611 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,20 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= +github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= +github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -22,6 +34,7 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/db/migrations/0004_track_bitrate.sql b/internal/db/migrations/0004_track_bitrate.sql new file mode 100644 index 0000000..8c874d6 --- /dev/null +++ b/internal/db/migrations/0004_track_bitrate.sql @@ -0,0 +1 @@ +ALTER TABLE tracks ADD COLUMN bitrate_kbps INTEGER; diff --git a/internal/library/service.go b/internal/library/service.go index 25689ee..c0abde6 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -37,6 +37,7 @@ type Track struct { AlbumTitle string `json:"albumTitle"` TrackNumber int `json:"trackNumber"` DurationSecs int `json:"durationSeconds"` + BitrateKbps int `json:"bitrateKbps"` FilePath string `json:"filePath"` ContentType string `json:"contentType"` CoverArtID string `json:"coverArtId"` @@ -260,7 +261,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) { rows, err := s.db.QueryContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), - COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') + COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id @@ -285,6 +286,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) { &track.AlbumTitle, &track.TrackNumber, &track.DurationSecs, + &track.BitrateKbps, &track.FilePath, &track.ContentType, &track.CoverArtID, @@ -303,7 +305,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) { err := s.db.QueryRowContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), - COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') + COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id @@ -318,6 +320,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) { &track.AlbumTitle, &track.TrackNumber, &track.DurationSecs, + &track.BitrateKbps, &track.FilePath, &track.ContentType, &track.CoverArtID, @@ -426,7 +429,7 @@ func (s *Service) RecentTracks(ctx context.Context, userID string, limit int) ([ rows, err := s.db.QueryContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), - COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') + COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id @@ -451,7 +454,7 @@ func (s *Service) RecentTracks(ctx context.Context, userID string, limit int) ([ rows, err := s.db.QueryContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), - COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') + COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id @@ -580,7 +583,7 @@ func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track, rows, err := s.db.QueryContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), - COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') + COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id @@ -780,7 +783,7 @@ func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, er rows, err := s.db.QueryContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), - COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') + COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM favorites f JOIN tracks t ON t.id = f.entity_id JOIN artists a ON a.id = t.artist_id @@ -810,6 +813,7 @@ func scanTracks(rows *sql.Rows) ([]Track, error) { &track.AlbumTitle, &track.TrackNumber, &track.DurationSecs, + &track.BitrateKbps, &track.FilePath, &track.ContentType, &track.CoverArtID, diff --git a/internal/scanner/service.go b/internal/scanner/service.go index 3f6342c..cb97eca 100644 --- a/internal/scanner/service.go +++ b/internal/scanner/service.go @@ -5,8 +5,10 @@ import ( "crypto/sha1" "database/sql" "encoding/hex" + "encoding/binary" "fmt" "io/fs" + "math" "os" "path/filepath" "slices" @@ -15,6 +17,8 @@ import ( "time" "github.com/dhowden/tag" + "github.com/hajimehoshi/go-mp3" + "github.com/mewkiz/flac" ) var supportedExtensions = []string{ @@ -75,6 +79,7 @@ type scannedTrack struct { TrackNumber int DiscNumber int DurationSecs int + BitrateKbps int FilePath string ContentType string } @@ -230,7 +235,7 @@ func (s *Service) runScan(ctx context.Context) (Result, error) { for _, track := range tracks { if _, err := tx.ExecContext( ctx, - `INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, bitrate_kbps, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, track.ID, track.AlbumID, track.ArtistID, @@ -238,6 +243,7 @@ func (s *Service) runScan(ctx context.Context) (Result, error) { track.TrackNumber, track.DiscNumber, track.DurationSecs, + track.BitrateKbps, track.FilePath, track.ContentType, now, @@ -336,6 +342,14 @@ func (s *Service) scanFile(path string) (scannedItem, error) { coverArt = findCoverArt(filepath.Dir(path)) } + fileInfo, statErr := file.Stat() + fileSize := int64(0) + if statErr == nil { + fileSize = fileInfo.Size() + } + contentType := detectContentType(path) + durationSecs, bitrateKbps := scanAudioProperties(path, contentType, fileSize) + return scannedItem{ artist: scannedArtist{ ID: artistID, @@ -356,9 +370,10 @@ func (s *Service) scanFile(path string) (scannedItem, error) { Title: title, TrackNumber: trackNumber, DiscNumber: discNumber, - DurationSecs: 0, + DurationSecs: durationSecs, + BitrateKbps: bitrateKbps, FilePath: path, - ContentType: detectContentType(path), + ContentType: contentType, }, }, nil } @@ -462,6 +477,120 @@ func coverExtension(mimeType string) string { } } +func scanAudioProperties(path, contentType string, fileSize int64) (int, int) { + durationSecs := extractDurationSeconds(path, contentType) + bitrateKbps := calculateBitrateKbps(fileSize, durationSecs) + return durationSecs, bitrateKbps +} + +func extractDurationSeconds(path, contentType string) int { + switch strings.ToLower(filepath.Ext(path)) { + case ".flac": + return extractFLACDurationSeconds(path) + case ".mp3": + return extractMP3DurationSeconds(path) + case ".wav": + return extractWAVDurationSeconds(path) + default: + _ = contentType + return 0 + } +} + +func extractFLACDurationSeconds(path string) int { + stream, err := flac.ParseFile(path) + if err != nil || stream == nil || stream.Info == nil || stream.Info.SampleRate == 0 || stream.Info.NSamples == 0 { + return 0 + } + return int(math.Round(float64(stream.Info.NSamples) / float64(stream.Info.SampleRate))) +} + +func extractMP3DurationSeconds(path string) int { + file, err := os.Open(path) + if err != nil { + return 0 + } + defer file.Close() + + decoder, err := mp3.NewDecoder(file) + if err != nil { + return 0 + } + if decoder.SampleRate() == 0 || decoder.Length() <= 0 { + return 0 + } + seconds := float64(decoder.Length()) / 4 / float64(decoder.SampleRate()) + return int(math.Round(seconds)) +} + +func extractWAVDurationSeconds(path string) int { + file, err := os.Open(path) + if err != nil { + return 0 + } + defer file.Close() + + header := make([]byte, 12) + if _, err := file.Read(header); err != nil { + return 0 + } + if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { + return 0 + } + + var byteRate uint32 + var dataSize uint32 + + for { + chunkHeader := make([]byte, 8) + if _, err := file.Read(chunkHeader); err != nil { + break + } + chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8]) + chunkID := string(chunkHeader[0:4]) + + switch chunkID { + case "fmt ": + if chunkSize < 16 { + return 0 + } + chunkData := make([]byte, chunkSize) + if _, err := file.Read(chunkData); err != nil { + return 0 + } + byteRate = binary.LittleEndian.Uint32(chunkData[8:12]) + case "data": + dataSize = chunkSize + if _, err := file.Seek(int64(chunkSize), 1); err != nil { + return 0 + } + default: + if _, err := file.Seek(int64(chunkSize), 1); err != nil { + return 0 + } + } + + if chunkSize%2 == 1 { + if _, err := file.Seek(1, 1); err != nil { + return 0 + } + } + + if byteRate > 0 && dataSize > 0 { + return int(math.Round(float64(dataSize) / float64(byteRate))) + } + } + + return 0 +} + +func calculateBitrateKbps(fileSize int64, durationSecs int) int { + if fileSize <= 0 || durationSecs <= 0 { + return 0 + } + return int(math.Round(float64(fileSize*8) / float64(durationSecs) / 1000)) +} + func (s *Service) tryMarkStarted() bool { s.mu.Lock() defer s.mu.Unlock()