feat: extract track duration and bitrate during scans
This commit is contained in:
@@ -32,6 +32,7 @@ export type Track = {
|
|||||||
albumTitle: string
|
albumTitle: string
|
||||||
trackNumber: number
|
trackNumber: number
|
||||||
durationSeconds: number
|
durationSeconds: number
|
||||||
|
bitrateKbps?: number
|
||||||
contentType?: string
|
contentType?: string
|
||||||
coverArtId?: string
|
coverArtId?: string
|
||||||
playCount?: number
|
playCount?: number
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function AlbumDetailPage() {
|
|||||||
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
|
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
|
||||||
<div className="text-base text-slate-400">{track.playCount ?? 0}</div>
|
<div className="text-base text-slate-400">{track.playCount ?? 0}</div>
|
||||||
<div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
|
<div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
|
||||||
<div className="text-base text-slate-200">—</div>
|
<div className="text-base text-slate-200">{formatBitrate(track.bitrateKbps)}</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
|
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,3 +187,10 @@ function formatQuality(track: Track) {
|
|||||||
}
|
}
|
||||||
return 'AUDIO'
|
return 'AUDIO'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBitrate(value?: number) {
|
||||||
|
if (!value) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return `${value} kbps`
|
||||||
|
}
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -12,7 +12,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/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/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
|||||||
13
go.sum
13
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
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/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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|||||||
1
internal/db/migrations/0004_track_bitrate.sql
Normal file
1
internal/db/migrations/0004_track_bitrate.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE tracks ADD COLUMN bitrate_kbps INTEGER;
|
||||||
@@ -37,6 +37,7 @@ type Track struct {
|
|||||||
AlbumTitle string `json:"albumTitle"`
|
AlbumTitle string `json:"albumTitle"`
|
||||||
TrackNumber int `json:"trackNumber"`
|
TrackNumber int `json:"trackNumber"`
|
||||||
DurationSecs int `json:"durationSeconds"`
|
DurationSecs int `json:"durationSeconds"`
|
||||||
|
BitrateKbps int `json:"bitrateKbps"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
CoverArtID string `json:"coverArtId"`
|
CoverArtID string `json:"coverArtId"`
|
||||||
@@ -260,7 +261,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
|
|||||||
rows, err := s.db.QueryContext(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
`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
|
FROM tracks t
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
JOIN albums al ON al.id = t.album_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.AlbumTitle,
|
||||||
&track.TrackNumber,
|
&track.TrackNumber,
|
||||||
&track.DurationSecs,
|
&track.DurationSecs,
|
||||||
|
&track.BitrateKbps,
|
||||||
&track.FilePath,
|
&track.FilePath,
|
||||||
&track.ContentType,
|
&track.ContentType,
|
||||||
&track.CoverArtID,
|
&track.CoverArtID,
|
||||||
@@ -303,7 +305,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
|
|||||||
err := s.db.QueryRowContext(
|
err := s.db.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
`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
|
FROM tracks t
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
JOIN albums al ON al.id = t.album_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.AlbumTitle,
|
||||||
&track.TrackNumber,
|
&track.TrackNumber,
|
||||||
&track.DurationSecs,
|
&track.DurationSecs,
|
||||||
|
&track.BitrateKbps,
|
||||||
&track.FilePath,
|
&track.FilePath,
|
||||||
&track.ContentType,
|
&track.ContentType,
|
||||||
&track.CoverArtID,
|
&track.CoverArtID,
|
||||||
@@ -426,7 +429,7 @@ func (s *Service) RecentTracks(ctx context.Context, userID string, limit int) ([
|
|||||||
rows, err := s.db.QueryContext(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
`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
|
FROM tracks t
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
JOIN albums al ON al.id = t.album_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(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
`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
|
FROM tracks t
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
JOIN albums al ON al.id = t.album_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(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
`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
|
FROM tracks t
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
JOIN albums al ON al.id = t.album_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(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
`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
|
FROM favorites f
|
||||||
JOIN tracks t ON t.id = f.entity_id
|
JOIN tracks t ON t.id = f.entity_id
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
@@ -810,6 +813,7 @@ func scanTracks(rows *sql.Rows) ([]Track, error) {
|
|||||||
&track.AlbumTitle,
|
&track.AlbumTitle,
|
||||||
&track.TrackNumber,
|
&track.TrackNumber,
|
||||||
&track.DurationSecs,
|
&track.DurationSecs,
|
||||||
|
&track.BitrateKbps,
|
||||||
&track.FilePath,
|
&track.FilePath,
|
||||||
&track.ContentType,
|
&track.ContentType,
|
||||||
&track.CoverArtID,
|
&track.CoverArtID,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -15,6 +17,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dhowden/tag"
|
"github.com/dhowden/tag"
|
||||||
|
"github.com/hajimehoshi/go-mp3"
|
||||||
|
"github.com/mewkiz/flac"
|
||||||
)
|
)
|
||||||
|
|
||||||
var supportedExtensions = []string{
|
var supportedExtensions = []string{
|
||||||
@@ -75,6 +79,7 @@ type scannedTrack struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
DurationSecs int
|
DurationSecs int
|
||||||
|
BitrateKbps int
|
||||||
FilePath string
|
FilePath string
|
||||||
ContentType string
|
ContentType string
|
||||||
}
|
}
|
||||||
@@ -230,7 +235,7 @@ func (s *Service) runScan(ctx context.Context) (Result, error) {
|
|||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
if _, err := tx.ExecContext(
|
if _, err := tx.ExecContext(
|
||||||
ctx,
|
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.ID,
|
||||||
track.AlbumID,
|
track.AlbumID,
|
||||||
track.ArtistID,
|
track.ArtistID,
|
||||||
@@ -238,6 +243,7 @@ func (s *Service) runScan(ctx context.Context) (Result, error) {
|
|||||||
track.TrackNumber,
|
track.TrackNumber,
|
||||||
track.DiscNumber,
|
track.DiscNumber,
|
||||||
track.DurationSecs,
|
track.DurationSecs,
|
||||||
|
track.BitrateKbps,
|
||||||
track.FilePath,
|
track.FilePath,
|
||||||
track.ContentType,
|
track.ContentType,
|
||||||
now,
|
now,
|
||||||
@@ -336,6 +342,14 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
|
|||||||
coverArt = findCoverArt(filepath.Dir(path))
|
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{
|
return scannedItem{
|
||||||
artist: scannedArtist{
|
artist: scannedArtist{
|
||||||
ID: artistID,
|
ID: artistID,
|
||||||
@@ -356,9 +370,10 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
|
|||||||
Title: title,
|
Title: title,
|
||||||
TrackNumber: trackNumber,
|
TrackNumber: trackNumber,
|
||||||
DiscNumber: discNumber,
|
DiscNumber: discNumber,
|
||||||
DurationSecs: 0,
|
DurationSecs: durationSecs,
|
||||||
|
BitrateKbps: bitrateKbps,
|
||||||
FilePath: path,
|
FilePath: path,
|
||||||
ContentType: detectContentType(path),
|
ContentType: contentType,
|
||||||
},
|
},
|
||||||
}, nil
|
}, 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 {
|
func (s *Service) tryMarkStarted() bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user