feat: extract track duration and bitrate during scans

This commit is contained in:
2026-04-03 02:34:07 +03:00
parent 2774b93830
commit 252075ee1c
7 changed files with 170 additions and 10 deletions

View File

@@ -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()