feat: extract track duration and bitrate during scans
This commit is contained in:
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"`
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user