Merge dbafe32135129eeb616c121512c9ba4db7f02354 into fd4a04339e43bf806d428c47ad6431c6f53402f9

This commit is contained in:
Pavel V. Sysolyatin 2026-01-14 14:23:32 +01:00 committed by GitHub
commit 4db48beb4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 3762 additions and 117 deletions

View File

@ -147,6 +147,7 @@ type scannerOptions struct {
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
PurgeMissing string // Values: "never", "always", "full"
CUESheetSupport string
}
type subsonicOptions struct {
@ -604,6 +605,7 @@ func setViperDefaults() {
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("scanner.cuesheetsupport", consts.CUEPreferEmbedded)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)

View File

@ -95,6 +95,15 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// CUE scan options
const (
CUEDisable = ""
CUEEmbedded = "embedded"
CUEExternal = "external"
CUEPreferEmbedded = "embedded,external"
CUEPreferExternal = "external,embedded"
)
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"

View File

@ -7,16 +7,28 @@ import (
"io"
"os"
"os/exec"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// NOTE: ffmpeg has a bug (https://trac.ffmpeg.org/ticket/8569) with write streaming cut from flac to flack.
//
// It set incorrect duration and start timestamp (take it from source file)
//
// We use workaround — write output to temp file with re-encoding, not copying stream, and then stream this file.
// It's slow down transcoding performance.
// When stream into other format (opus for example no issue found)
const forbiddenSubTrackTranscodingFormat = "flac"
type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
Transcode(ctx context.Context, command string, targetFormat string, mf *model.MediaFile, maxBitRate int, beginOffset int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
@ -29,22 +41,62 @@ func New() FFmpeg {
}
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
rawTranscodeCmd = "ffmpeg -v 0 -i %s -map 0:a:0 -vn -"
waveTranscodeCmd = "ffmpeg -i %s -c:a pcm_s16le -f wav -"
)
func disableStreamCopy(mf *model.MediaFile, targetFormat string) bool {
return mf.Suffix == targetFormat && targetFormat == forbiddenSubTrackTranscodingFormat && isSubTrack(mf)
}
func forceStreamCopy(mf *model.MediaFile, targetFormat string) bool {
if isSubTrack(mf) {
return mf.Suffix == targetFormat && targetFormat != "wav" && targetFormat != forbiddenSubTrackTranscodingFormat
}
return false
}
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, command string, format string, mf *model.MediaFile, maxBitRate int, beginOffset int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
sourcePath := mf.AbsolutePath()
// First make sure the file exists
if err := fileExists(path); err != nil {
if err := fileExists(sourcePath); err != nil {
return nil, err
}
args := createFFmpegCommand(command, path, maxBitRate, offset)
return e.start(ctx, args)
intermediatePath := ""
if disableStreamCopy(mf, format) {
var err error
intermediatePath, err = os.MkdirTemp("", "intermediate-")
if err != nil {
return nil, err
}
intermediatePath = path.Join(intermediatePath, fmt.Sprintf("media-%s-%d", mf.ID, mf.SubTrack))
}
var fileReader io.ReadCloser
if isSubTrack(mf) {
fileReader = openWVFromISO(sourcePath)
if fileReader != nil {
sourcePath = "-"
}
}
if command == model.RawTranscodeCmd {
if format == "wav" {
command = waveTranscodeCmd
} else {
command = rawTranscodeCmd
}
}
args := createFFmpegCommandForMedia(command, format, sourcePath, intermediatePath, mf, maxBitRate, beginOffset)
return e.start(ctx, args, fileReader, intermediatePath)
}
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
@ -55,8 +107,16 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if err := fileExists(path); err != nil {
return nil, err
}
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
return e.start(ctx, args)
args := createFFmpegCommand(extractImageCmd, path)
fileReader := openWVFromISO(path)
if fileReader != nil {
for i, s := range args {
if s == path {
args[i] = "-"
}
}
}
return e.start(ctx, args, fileReader, "")
}
func fileExists(path string) error {
@ -108,10 +168,11 @@ func (e *ffmpeg) Version() string {
return parts[2]
}
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
func (e *ffmpeg) start(ctx context.Context, args []string, fileReader io.ReadCloser, tempFilePath string) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
j := &ffCmd{args: args, intermediatePath: tempFilePath}
j.PipeReader, j.out = io.Pipe()
j.in = fileReader
err := j.start(ctx)
if err != nil {
return nil, err
@ -122,14 +183,19 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
type ffCmd struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
out *io.PipeWriter
args []string
cmd *exec.Cmd
intermediatePath string
in io.ReadCloser
inWriter io.WriteCloser
}
func (j *ffCmd) start(ctx context.Context) error {
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if j.intermediatePath == "" {
cmd.Stdout = j.out
}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
} else {
@ -137,13 +203,48 @@ func (j *ffCmd) start(ctx context.Context) error {
}
j.cmd = cmd
if err := cmd.Start(); err != nil {
if j.in != nil {
var err error
j.inWriter, err = cmd.StdinPipe()
if err != nil {
_ = j.in.Close()
log.Error("Can't open ffmpeg stdin pipe", "error", err)
return err
}
}
if err := j.cmd.Start(); err != nil {
return fmt.Errorf("starting cmd: %w", err)
}
return nil
}
func (j *ffCmd) streamToInput() {
defer func() {
log.Debug("close pipe source")
_ = j.in.Close()
_ = j.inWriter.Close()
}()
wrote, err := io.Copy(j.inWriter, j.in)
if err != nil {
if !errors.Is(err, io.EOF) {
log.Error("Error copying input stream to ffmpeg", "error", err)
}
return
}
log.Debug("Wrote data", "bytes", wrote)
}
func (j *ffCmd) wait() {
if j.intermediatePath != "" {
defer func() {
_ = os.RemoveAll(path.Dir(j.intermediatePath))
}()
}
if j.inWriter != nil {
j.streamToInput()
}
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
@ -153,25 +254,171 @@ func (j *ffCmd) wait() {
}
return
}
if j.intermediatePath != "" {
f, err := os.Open(j.intermediatePath)
if err != nil {
_ = j.out.CloseWithError(fmt.Errorf("failed to open intermediate media '%s': %w", j.intermediatePath, err))
return
}
_, err = io.Copy(j.out, f)
if err != nil {
_ = j.out.CloseWithError(fmt.Errorf("failed to copy data from intermediate media '%s': %w", j.intermediatePath, err))
return
}
_ = f.Close()
}
_ = j.out.Close()
}
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string
for _, s := range fixCmd(cmd) {
if strings.Contains(s, "%s") {
s = strings.ReplaceAll(s, "%s", path)
args = append(args, s)
if offset > 0 && !strings.Contains(cmd, "%t") {
args = append(args, "-ss", strconv.Itoa(offset))
}
} else {
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
args = append(args, s)
func makeMetadataParams(mf *model.MediaFile) []string {
var result []string
// Fill metadata only for multi-track media
if !isSubTrack(mf) {
return nil
}
result = append(result, "-metadata", fmt.Sprintf(`title=%s`, mf.Title))
result = append(result, "-metadata", fmt.Sprintf(`artist=%s`, mf.Artist))
result = append(result, "-metadata", fmt.Sprintf(`album=%s`, mf.Album))
if mf.AlbumArtist != "" {
result = append(result, "-metadata", fmt.Sprintf(`album_artist=%s`, mf.AlbumArtist))
}
result = append(result, "-metadata", fmt.Sprintf("year=%d", mf.Year))
result = append(result, "-metadata", fmt.Sprintf("track=%d", mf.TrackNumber))
if mf.DiscNumber > 0 {
result = append(result, "-metadata", fmt.Sprintf("disc=%d", mf.DiscNumber))
}
result = append(result, "-metadata", fmt.Sprintf(`comment=%s`, mf.Comment))
result = append(result, "-metadata", fmt.Sprintf(`genre=%s`, mf.Genre))
if mf.RGAlbumGain != nil {
result = append(result, "-metadata", fmt.Sprintf("replaygain_album_gain=%f", *mf.RGAlbumGain))
}
if mf.RGAlbumPeak != nil {
result = append(result, "-metadata", fmt.Sprintf("replaygain_album_peak=%f", *mf.RGAlbumPeak))
}
if mf.RGTrackGain != nil {
result = append(result, "-metadata", fmt.Sprintf("replaygain_track_gain=%f", *mf.RGTrackGain))
}
if mf.RGTrackPeak != nil {
result = append(result, "-metadata", fmt.Sprintf("replaygain_track_peak=%f", *mf.RGTrackPeak))
}
result = append(result, "-metadata", "cuesheet=")
return result
}
var zeroTime = time.Unix(0, 0).UTC()
func makeTime(timeInSeconds float32) string {
return zeroTime.Add(time.Duration(timeInSeconds*1000.0) * time.Millisecond).Format("15:04:05.000")
}
func makeRangeParams(mf *model.MediaFile, extraOffset int) []string {
var result []string
// Insert range only for multi-track media
if isSubTrack(mf) {
if mf.Offset+float32(extraOffset) > 0.0 {
result = append(result, "-ss", makeTime(mf.Offset+float32(extraOffset)))
}
if mf.Duration > 0.0 {
result = append(result, "-t", makeTime(mf.Duration))
}
}
return result
}
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string) []string {
split := strings.Split(cmd, " ")
for i, s := range split {
switch s {
case "%s":
split[i] = path
default:
}
}
return split
}
func createFFmpegCommandForMedia(cmd string, format string, sourcePath string, intermediatePath string, mf *model.MediaFile, maxBitRate int, beginOffset int) []string {
offsetIndex := -1
destFormat := ""
copyIndex := -1
inputParsed := false
var args []string
split := strings.Split(cmd, " ")
for i, s := range split {
var preArgs []string
var postArgs []string
switch s {
case "-vn":
copyIndex = len(args)
case "-i":
// Need insert start offset and duration before input file
if isSubTrack(mf) {
preArgs = makeRangeParams(mf, beginOffset)
}
inputParsed = true
case "-":
if destFormat == "" {
destFormat = format
preArgs = append(preArgs, "-f", destFormat)
}
// Insert metadata before last param for sub-tracks
if isSubTrack(mf) {
preArgs = append(preArgs, makeMetadataParams(mf)...)
if intermediatePath != "" {
s = intermediatePath
}
}
case "%t":
if !isSubTrack(mf) {
s = fmt.Sprintf("%v", beginOffset)
offsetIndex = -1
}
case "%s":
s = sourcePath // mf.Path or '-'
if beginOffset > 0 && !isSubTrack(mf) {
offsetIndex = len(args) + 1
}
case "-f":
// Next param is the output format
if inputParsed {
destFormat = split[i+1]
}
default:
if strings.Contains(s, "%b") {
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
}
}
if len(preArgs) > 0 {
args = append(args, preArgs...)
}
if s != "" {
args = append(args, s)
}
if len(postArgs) > 0 {
args = append(args, postArgs...)
}
}
if offsetIndex != -1 {
offsetParams := []string{"-ss", fmt.Sprintf("%v", beginOffset)}
args = append(args[:offsetIndex], append(offsetParams, args[offsetIndex:]...)...)
}
if copyIndex != -1 && forceStreamCopy(mf, destFormat) {
args = append(args[:copyIndex], append([]string{"-c:a copy"}, args[copyIndex:]...)...)
}
return args
}

View File

@ -7,10 +7,12 @@ import (
"testing"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
)
func TestFFmpeg(t *testing.T) {
@ -26,26 +28,124 @@ var _ = Describe("ffmpeg", func() {
ffmpegPath = "ffmpeg"
ffmpegErr = nil
})
Describe("createFFmpegCommand", func() {
Describe("createFFmpegCommandForMedia", func() {
It("creates a valid command line", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
mf := model.MediaFile{
Path: "/music library/file.mp3",
SubTrack: -1,
}
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "mp3", mf.Path, "", &mf, 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-f", "mp3", "-"}))
})
It("handles extra spaces in the command string", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
mf := model.MediaFile{
Path: "/music library/file.mp3",
SubTrack: -1,
}
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "mp3", mf.Path, "", &mf, 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-f", "mp3", "-"}))
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
mf := model.MediaFile{
Path: "/music library/file.mp3",
SubTrack: -1,
}
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -ss %t -", "mp3", mf.Path, "", &mf, 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "-f", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
It("adds time offset after the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
mf := model.MediaFile{
Path: "/music library/file.mp3",
SubTrack: -1,
}
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "mp3", mf.Path, "", &mf, 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "-f", "mp3", "-"}))
})
})
Context("for subtracks", func() {
It("adds time only duration before -i", func() {
mf := model.MediaFile{
Path: "/music library/file.ape",
SubTrack: 1,
Offset: 0,
Duration: 5.0,
Title: "title",
Artist: "Artist",
Album: "Album",
Year: 2019,
TrackNumber: 5,
Comment: "c",
Genre: "rock",
}
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -f mp3 -", "ogg", mf.Path, "", &mf, 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-t", "00:00:05.000", "-i", "/music library/file.ape", "-b:a", "123k", "-f", "mp3",
"-metadata", "title=title",
"-metadata", "artist=Artist",
"-metadata", "album=Album",
"-metadata", "year=2019",
"-metadata", "track=5",
"-metadata", "comment=c",
"-metadata", "genre=rock",
"-metadata", "cuesheet=", "-"}))
})
})
Context("for subtracks", func() {
It("use source path instead of mediafile path", func() {
mf := model.MediaFile{
Path: "/music library/file.wv",
Suffix: "wv",
SubTrack: 1,
Offset: 0,
Duration: 5.0,
Title: "title",
Artist: "Artist",
Album: "Album",
Year: 2019,
TrackNumber: 5,
Comment: "c",
Genre: "rock",
}
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "flac", "-", "", &mf, 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-t", "00:00:05.000", "-i", "-", "-b:a", "123k", "-f", "flac",
"-metadata", "title=title",
"-metadata", "artist=Artist",
"-metadata", "album=Album",
"-metadata", "year=2019",
"-metadata", "track=5",
"-metadata", "comment=c",
"-metadata", "genre=rock",
"-metadata", "cuesheet=", "-"}))
})
})
Context("for subtracks", func() {
It("flac to flac use intermediate path and no copy stream", func() {
mf := model.MediaFile{
Path: "/music library/file.flac",
Suffix: "flac",
SubTrack: 1,
Offset: 0,
Duration: 5.0,
Title: "title",
Artist: "Artist",
Album: "Album",
Year: 2019,
TrackNumber: 5,
Comment: "c",
Genre: "rock",
}
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "flac", mf.Path, "intermediate", &mf, 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-t", "00:00:05.000", "-i", "/music library/file.flac", "-b:a", "123k", "-f", "flac",
"-metadata", "title=title",
"-metadata", "artist=Artist",
"-metadata", "album=Album",
"-metadata", "year=2019",
"-metadata", "track=5",
"-metadata", "comment=c",
"-metadata", "genre=rock",
"-metadata", "cuesheet=", "intermediate"}))
})
})
})
@ -90,10 +190,15 @@ var _ = Describe("ffmpeg", func() {
// Use a command that generates audio indefinitely
// -f lavfi uses FFmpeg's built-in audio source
// -t 0 means no time limit (runs forever)
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -"
mf := model.MediaFile{
Path: "tests/fixtures/test.mp3",
SubTrack: -1,
}
// The input file is not used here, but we need to provide a valid path to the Transcode function
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
stream, err := ff.Transcode(ctx, command, "opus", &mf, 128, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
@ -114,8 +219,13 @@ var _ = Describe("ffmpeg", func() {
ctx, cancel := context.WithCancel(GinkgoT().Context())
cancel() // Cancel immediately
mf := model.MediaFile{
Path: "tests/fixtures/test.mp3",
SubTrack: -1,
}
// This should fail immediately
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
_, err := ff.Transcode(ctx, "ffmpeg -i %s -", "opus", &mf, 128, 0)
Expect(err).To(MatchError(context.Canceled))
})
})
@ -141,8 +251,13 @@ var _ = Describe("ffmpeg", func() {
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()
mf := model.MediaFile{
Path: "tests/fixtures/test.mp3",
SubTrack: -1,
}
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
stream, err := ff.Transcode(ctx, longRunningCmd, "opus", &mf, 0, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()

127
core/ffmpeg/iso_reader.go Normal file
View File

@ -0,0 +1,127 @@
package ffmpeg
import (
"errors"
"io"
"os"
"strings"
"github.com/kdomanski/iso9660"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
var ErrorNotAnISO = errors.New("not iso file")
type isoWVReader struct {
f *os.File
i *iso9660.Image
r io.Reader
}
func (r *isoWVReader) Read(p []byte) (int, error) {
if r.r != nil {
return r.r.Read(p)
}
return 0, io.ErrClosedPipe
}
func (r *isoWVReader) Close() error {
if r.f != nil {
return r.f.Close()
}
return nil
}
func isISO(f *os.File) bool {
_, err := f.Seek(32769, 0)
if err != nil {
return false
}
buf := make([]byte, 5)
count, err := f.Read(buf)
if err != nil {
return false
}
if count != 5 || len(buf) < 5 {
return false
}
if !(buf[0] == 0x43 && buf[1] == 0x44 &&
buf[2] == 0x30 && buf[3] == 0x30 &&
buf[4] == 0x31) {
return false
}
_, err = f.Seek(0, 0)
return err == nil
}
func (r *isoWVReader) open(path string) error {
var err error
r.f, err = os.Open(path)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = r.f.Close()
r.f = nil
}
}()
if !isISO(r.f) {
return ErrorNotAnISO
}
r.i, err = iso9660.OpenImage(r.f)
if err != nil {
return err
}
root, err := r.i.RootDir()
if err != nil {
return err
}
children, err := root.GetChildren()
if err != nil {
return err
}
for _, entry := range children {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".WV") {
continue
}
r.r = entry.Reader()
return nil
}
return nil
}
func openWV(path string) io.ReadCloser {
reader := isoWVReader{}
if err := reader.open(path); err != nil {
if !errors.Is(err, ErrorNotAnISO) {
log.Error("Can't open ISO image file", "error", err)
}
return nil
}
return &reader
}
func isSubTrack(mf *model.MediaFile) bool {
return mf.SubTrack > -1
}
func openWVFromISO(path string) io.ReadCloser {
if !(strings.HasSuffix(path, ".wv") || strings.HasSuffix(path, ".iso.wv")) {
return nil
}
return openWV(path)
}

View File

@ -6,10 +6,12 @@ import (
"io"
"mime"
"os"
"slices"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
intMime "github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
@ -45,7 +47,7 @@ type streamJob struct {
}
func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
return fmt.Sprintf("%s.%s.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.mf.SubTrack, j.bitRate, j.format, j.offset)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
@ -57,13 +59,32 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
}
func (ms *mediaStreamer) rawStream(stream *Stream, mf *model.MediaFile) (*Stream, error) {
f, err := os.Open(mf.Path)
if err != nil {
return nil, err
}
stream.ReadCloser = f
stream.Seeker = f
stream.format = mf.Suffix
return stream, nil
}
func (ms *mediaStreamer) allowStreaming(mf *model.MediaFile) bool {
if mf.SubTrack > -1 {
return false
}
// TODO: Decide on information from players supported format
return mf.Suffix != "ape" && mf.Suffix != "wv"
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
var format string
var bitRate int
var cached bool
defer func() {
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != mf.Suffix,
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
@ -71,19 +92,12 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
filePath := mf.AbsolutePath()
if format == "raw" {
if format == "raw" && ms.allowStreaming(mf) {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
s.ReadCloser = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
return ms.rawStream(s, mf)
}
job := &streamJob{
@ -199,10 +213,30 @@ func NewTranscodingCache() TranscodingCache {
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
command := ""
var targetFormat string
if job.mf.SubTrack > -1 {
// If the source format is lossless, transcode to WAV
// to preserve quality when no specific target format is set.
// This is a temporary measure until we can determine
// the player's supported lossless formats.
if slices.Contains(intMime.LosslessFormats, job.mf.Suffix) {
targetFormat = "wav"
} else {
targetFormat = conf.Server.DefaultDownsamplingFormat
}
command = model.RawTranscodeCmd
log.Trace("Raw transcoding", "sourceFormat", job.mf.Suffix, "targetFormat", targetFormat)
} else {
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
targetFormat = t.TargetFormat
command = t.Command
}
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
@ -217,7 +251,7 @@ func NewTranscodingCache() TranscodingCache {
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
out, err := job.ms.transcoder.Transcode(transcodingCtx, command, targetFormat, job.mf, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid

View File

@ -5,14 +5,15 @@ import (
"io"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaStreamer", func() {
@ -27,7 +28,7 @@ var _ = Describe("MediaStreamer", func() {
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0, SubTrack: -1},
})
testCache := core.NewTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())

View File

@ -0,0 +1,38 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upSupportMediaWithSubtracks, downSupportMediaWithSubtracks)
}
func upSupportMediaWithSubtracks(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
-- Add offset and sub_track columns
ALTER TABLE media_file ADD COLUMN offset real DEFAULT 0 NOT NULL;
ALTER TABLE media_file ADD COLUMN sub_track integer DEFAULT -1 NOT NULL;
ALTER TABLE media_file ADD COLUMN cuefile varchar(255) DEFAULT '' NOT NULL;
`)
if err != nil {
return err
}
notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.")
return forceFullRescan(tx)
}
func downSupportMediaWithSubtracks(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
-- Remove data for sub tracks
DELETE FROM media_file WHERE sub_track != -1;
-- Remove offset and sub_track columns
ALTER TABLE media_file DROP COLUMN offset DEFAULT 0 NOT NULL;
ALTER TABLE media_file DROP COLUMN sub_track integer DEFAULT -1 NOT NULL;
ALTER TABLE media_file DROP COLUMN cuefile varchar(255) DEFAULT '' NOT NULL;
`)
return err
}

1
go.mod
View File

@ -36,6 +36,7 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/kardianos/service v1.2.4
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kdomanski/iso9660 v0.4.0
github.com/knqyf263/go-plugin v0.9.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6

2
go.sum
View File

@ -130,6 +130,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg=
github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=

View File

@ -28,3 +28,8 @@ func IsValidPlaylist(filePath string) bool {
extension := strings.ToLower(filepath.Ext(filePath))
return extension == ".m3u" || extension == ".m3u8" || extension == ".nsp"
}
func IsCueSheetFile(filePath string) bool {
extension := strings.ToLower(filepath.Ext(filePath))
return extension == ".cue"
}

View File

@ -3,9 +3,10 @@ package model_test
import (
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/model"
)
var _ = Describe("File Types()", func() {
@ -58,4 +59,14 @@ var _ = Describe("File Types()", func() {
Expect(model.IsValidPlaylist("testm3u")).To(BeFalse())
})
})
Describe("IsCueSheetFile()", func() {
It("returns true for a CUE file", func() {
Expect(model.IsCueSheetFile(filepath.Join("path", "to", "test.cue"))).To(BeTrue())
})
It("returns false for a non-CUE file", func() {
Expect(model.IsCueSheetFile("test.mp3")).To(BeFalse())
})
})
})

View File

@ -13,6 +13,7 @@ import (
"time"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils"
@ -84,6 +85,9 @@ type MediaFile struct {
RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
RGTrackGain *float64 `structs:"rg_track_gain" json:"rgTrackGain"`
RGTrackPeak *float64 `structs:"rg_track_peak" json:"rgTrackPeak"`
Offset float32 `structs:"offset" json:"offset"` // Track offset in seconds for sub-tracks
SubTrack int `structs:"sub_track" json:"sub_track"` // Index of the sub-track in a multi-track media, -1 if not a sub-track
CUEFile string `structs:"cuefile" json:"cuefile" hash:"ignore"` // CUE file if this is a sub-track mediafile created from an external CUE sheet
Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags from the original file
Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this track
@ -187,7 +191,7 @@ func (mfs MediaFiles) ToAlbum() Album {
a.Missing = true
embedArtPath := ""
embedArtDisc := 0
for _, m := range mfs {
for i, m := range mfs {
// We assume these attributes are all the same for all songs in an album
a.ID = m.AlbumID
a.LibraryID = m.LibraryID
@ -206,7 +210,10 @@ func (mfs MediaFiles) ToAlbum() Album {
// Calculated attributes based on aggregations
a.Duration += m.Duration
a.Size += m.Size
// Don't sum sub tracks sizes
if m.SubTrack < 0 || (m.SubTrack >= 0 && i == 0) {
a.Size += m.Size
}
years = append(years, m.Year)
dates = append(dates, m.Date)
originalYears = append(originalYears, m.OriginalYear)

View File

@ -89,12 +89,28 @@ var _ = Describe("MediaFiles", func() {
})
})
When("we have subtracks", func() {
BeforeEach(func() {
mfs = MediaFiles{
{Duration: 100.2, Size: 2024, SubTrack: 0},
{Duration: 200.2, Size: 2024, SubTrack: 1},
{Duration: 150.6, Size: 2024, SubTrack: 2},
}
})
It("calculates the aggregates correctly", func() {
album := mfs.ToAlbum()
Expect(album.Duration).To(Equal(float32(451.0)))
Expect(album.Size).To(Equal(int64(2024)))
})
})
When("we have multiple songs with different dates", func() {
BeforeEach(func() {
mfs = MediaFiles{
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")},
{Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30")},
{Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30")},
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30"), SubTrack: -1},
{Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30"), SubTrack: -1},
{Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30"), SubTrack: -1},
}
})
It("calculates the aggregates correctly", func() {

View File

@ -0,0 +1,756 @@
package cue
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/log"
)
const (
delims = "\t\n\r "
framesPerSecond = 75 // is based on audio CD sectors and 44100 Hz sample rate
remGenre = "GENRE"
remComment = "COMMENT"
remDate = "DATE"
remDiskID = "DISCID"
remRGAlbumGain = "REPLAYGAIN_ALBUM_GAIN"
remRGAlbumPeak = "REPLAYGAIN_ALBUM_PEAK"
remRGTrackGain = "REPLAYGAIN_TRACK_GAIN"
remRGTrackPeak = "REPLAYGAIN_TRACK_PEAK"
remDiscNumber = "DISCNUMBER"
remTotalDiscs = "TOTALDISCS"
)
type ExpectedFields int
const (
ExpectCommon ExpectedFields = iota
ExpectTracks
ExpectTrack
)
// Frame represent track frame
type Frame uint64
// Flags for track
type Flags int
// RemData data from REM lines
type RemData map[string]string
const (
// None - no flags
None Flags = iota
Dcp = 1 << iota
FourCh = 1 << iota
Pre = 1 << iota
Scms = 1 << iota
)
var (
ErrorParseCUE = fmt.Errorf("no CUE data")
ErrorFrameFormat = fmt.Errorf("invalid frame format")
ErrorInvalidISRC = fmt.Errorf("invalid ISRC")
ErrorInvalidCatalog = fmt.Errorf("invalid CATALOG")
ErrorInvalidText = fmt.Errorf("invalid text")
ErrorUnclosedQuote = fmt.Errorf("unclosed quote in string")
ErrorMissingFile = fmt.Errorf("no FILE")
ErrorMissingIndex = fmt.Errorf("no INDEX")
ErrorMissingTrack = fmt.Errorf("no TRACK")
ErrorTrackOutOfOrder = fmt.Errorf("track out of order")
ErrorIndexOutOfOrder = fmt.Errorf("index out of order")
ErrorExpectedTrackIndent = fmt.Errorf("expected track indent")
ErrorDuplicateCatalog = fmt.Errorf("duplicate CATALOG")
ErrorDuplicateCdTextFile = fmt.Errorf("duplicate CDTEXTFILE")
ErrorDuplicatePerformer = fmt.Errorf("duplicate PERFORMER")
ErrorDuplicateTitle = fmt.Errorf("duplicate TITLE")
ErrorDuplicateSongwriter = fmt.Errorf("duplicate SONGWRITER")
ErrorDuplicateTrackPerformer = fmt.Errorf("duplicate track PERFORMER")
ErrorDuplicateTrackTitle = fmt.Errorf("duplicate track TITLE")
ErrorDuplicateTrackSongwriter = fmt.Errorf("duplicate track SONGWRITER")
ErrorDuplicateTrackFlags = fmt.Errorf("duplicate track FLAGS")
ErrorDuplicateTrackISRC = fmt.Errorf("duplicate track ISRC")
ErrorDuplicateTrackPostGap = fmt.Errorf("duplicate track POSTGAP")
ErrorDuplicateTrackPreGap = fmt.Errorf("duplicate track PREGAP")
)
var (
catalogRegex = regexp.MustCompile(`^\d{13}$`)
isrcRegex = regexp.MustCompile(`^[\da-zA-Z]{12}$`)
cueRegex = regexp.MustCompile(`^\S+( )+\S+.+`)
)
// TrackIndex data
type TrackIndex struct {
Number uint
Frame Frame
}
func frameFromString(str string) (Frame, error) {
v := strings.Split(str, ":")
if len(str) < 8 {
return 0, ErrorFrameFormat
}
if len(v) == 3 {
mm, _ := strconv.ParseUint(v[0], 10, 32)
ss, _ := strconv.ParseUint(v[1], 10, 32)
if ss > 59 {
return 0, ErrorFrameFormat
}
ff, _ := strconv.ParseUint(v[2], 10, 32)
if ff >= framesPerSecond {
return 0, ErrorFrameFormat
}
return Frame((mm*60+ss)*framesPerSecond + ff), nil
}
return 0, ErrorFrameFormat
}
func (frame Frame) Duration() time.Duration {
seconds := time.Second * time.Duration(frame/framesPerSecond)
milliseconds := time.Millisecond * time.Duration(float64(frame%framesPerSecond)/0.075)
return seconds + milliseconds
}
func (frame Frame) String() string {
seconds := frame / framesPerSecond
minutes := seconds / 60
seconds %= 60
ff := frame % framesPerSecond
return fmt.Sprintf("%.2d:%.2d:%.2d", minutes, seconds, ff)
}
// Track instance
type Track struct {
Rem RemData
TrackNumber uint
TrackDataType string
Flags Flags
ISRC string
Title string
Performer string
SongWriter string
PreGap Frame
PostGap Frame
Index []TrackIndex
}
// File instance
type File struct {
FileName string
FileType string
Tracks []Track
}
// Cuesheet instance
type Cuesheet struct {
Rem RemData
Catalog string
CdTextFile string
Title string
Performer string
SongWriter string
Pregap Frame
Postgap Frame
File []File
}
func (track *Track) GetStartOffset() time.Duration {
if len(track.Index) > 1 && track.Index[0].Number == 0 {
return track.Index[1].Frame.Duration()
}
return track.Index[0].Frame.Duration()
}
func ReadFromFileFS(fs fs.FS, filePath string) (*Cuesheet, error) {
file, err := fs.Open(filePath)
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Error("Can't close CUE file", "filePath", filePath)
}
}()
return ReadCue(file)
}
// ReadFromFile load CUESheet from file
func ReadFromFile(filePath string) (*Cuesheet, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Error("Can't close CUE file", "filePath", filePath)
}
}()
return ReadCue(file)
}
func setTagOrError(value *string, newValue string, err error, noEmpty bool) error {
if len(*value) > 0 {
return err
}
*value = newValue
if noEmpty && *value == "" {
return ErrorInvalidText
}
return nil
}
func readCUERem(cuesheet *Cuesheet, line string) error {
if cuesheet.Rem == nil {
cuesheet.Rem = RemData{}
}
key, err := readString(&line)
if err != nil {
return fmt.Errorf("REM command key: %w", err)
}
trimmedLine := strings.TrimLeft(line, delims)
if len(trimmedLine) > 0 && isQuoted(trimmedLine) {
value, closed := unquote(trimmedLine)
if !closed {
return ErrorUnclosedQuote
}
cuesheet.Rem[key] = value
} else {
cuesheet.Rem[key] = line
}
return nil
}
func readCUECatalog(cuesheet *Cuesheet, line string) error {
if len(cuesheet.Catalog) > 0 {
return ErrorDuplicateCatalog
}
if !catalogRegex.MatchString(line) {
return ErrorInvalidCatalog
}
cuesheet.Catalog = line
return nil
}
func readCUECdTextFile(cuesheet *Cuesheet, line string) error {
value, err := readString(&line)
if err != nil {
log.Warn("Unclosed quote in CDTEXTFILE, using rest of line", "value", value)
}
return setTagOrError(&cuesheet.CdTextFile, value, ErrorDuplicateCdTextFile, false)
}
func readCUETitle(cuesheet *Cuesheet, line string) error {
value, err := readString(&line)
if err != nil {
log.Warn("Unclosed quote in TITLE, using rest of line", "value", value)
}
err = setTagOrError(&cuesheet.Title, value, ErrorDuplicateTitle, true)
if errors.Is(err, ErrorDuplicateTitle) {
log.Error(fmt.Sprintf("already has title '%s' / '%s'", cuesheet.Title, line))
}
return err
}
func readCUEPerformer(cuesheet *Cuesheet, line string) error {
value, err := readString(&line)
if err != nil {
log.Warn("Unclosed quote in PERFORMER, using rest of line", "value", value)
}
return setTagOrError(&cuesheet.Performer, value, ErrorDuplicatePerformer, true)
}
func readCUESongwriter(cuesheet *Cuesheet, line string) error {
value, err := readString(&line)
if err != nil {
log.Warn("Unclosed quote in SONGWRITER, using rest of line", "value", value)
}
return setTagOrError(&cuesheet.SongWriter, value, ErrorDuplicateSongwriter, true)
}
func readCUEPreGap(cuesheet *Cuesheet, line string) error {
value, err := readString(&line)
if err != nil {
return fmt.Errorf("PREGAP: %w", err)
}
cuesheet.Pregap, err = frameFromString(value)
return err
}
func readCUEPostGap(cuesheet *Cuesheet, line string) error {
value, err := readString(&line)
if err != nil {
return fmt.Errorf("POSTGAP: %w", err)
}
cuesheet.Postgap, err = frameFromString(value)
return err
}
func readCUEFile(cuesheet *Cuesheet, line string) error {
fileName, err := readString(&line)
if err != nil {
return fmt.Errorf("FILE command filename: %w", err)
}
fileType, err := readString(&line)
if err != nil {
return fmt.Errorf("FILE command type: %w", err)
}
cuesheet.File = append(cuesheet.File, File{FileName: fileName, FileType: fileType})
return nil
}
func readCUEFields(cuesheet *Cuesheet, line string) error {
command, err := readString(&line)
if err != nil {
return fmt.Errorf("reading command: %w", err)
}
command = strings.ToUpper(command)
switch command {
case "REM":
return readCUERem(cuesheet, line)
case "CATALOG":
return readCUECatalog(cuesheet, line)
case "CDTEXTFILE":
return readCUECdTextFile(cuesheet, line)
case "TITLE":
return readCUETitle(cuesheet, line)
case "PERFORMER":
return readCUEPerformer(cuesheet, line)
case "SONGWRITER":
return readCUESongwriter(cuesheet, line)
case "PREGAP":
return readCUEPreGap(cuesheet, line)
case "POSTGAP":
return readCUEPostGap(cuesheet, line)
case "FILE":
return readCUEFile(cuesheet, line)
default:
return nil
}
}
func readFileFields(file *File, line string) (bool, error) {
command, err := readString(&line)
if err != nil {
return false, fmt.Errorf("reading command: %w", err)
}
command = strings.ToUpper(command)
switch command {
case "TRACK":
track := Track{}
track.TrackNumber, err = readUint(&line)
if err != nil {
return false, fmt.Errorf("TRACK number: %w", err)
}
if len(file.Tracks) != int(track.TrackNumber)-1 {
return false, ErrorTrackOutOfOrder
}
track.TrackDataType, err = readString(&line)
if err != nil {
return false, fmt.Errorf("TRACK data type: %w", err)
}
file.Tracks = append(file.Tracks, track)
return true, nil
default:
}
return false, nil
}
func parseFlag(flag string) Flags {
switch flag {
case "DCP":
return Dcp
case "4CH":
return FourCh
case "PRE":
return Pre
case "SCMS":
return Scms
default:
return None
}
}
func readTrackFlags(track *Track, line string) error {
if track.Flags != None {
return ErrorDuplicateTrackFlags
}
track.Flags = None
for len(line) > 0 {
flag, err := readString(&line)
if err != nil {
log.Warn("Unclosed quote in FLAGS, using rest of line", "value", flag)
if flag != "" {
track.Flags |= parseFlag(flag)
}
break
}
track.Flags |= parseFlag(flag)
}
return nil
}
func readTrackISRC(track *Track, line string) error {
if len(track.ISRC) > 0 {
return ErrorDuplicateTrackISRC
}
if !isrcRegex.MatchString(line) {
return ErrorInvalidISRC
}
track.ISRC = line
return nil
}
func readTrackStringField(value *string, line string, duplicateErr error, fieldName string) error {
v, closed := unquote(line)
if !closed {
log.Warn(fmt.Sprintf("Unclosed quote in track %s, using rest of line", fieldName), "value", v)
}
return setTagOrError(value, v, duplicateErr, true)
}
func readTrackPreGap(track *Track, line string) error {
if track.PreGap > 0 {
return ErrorDuplicateTrackPreGap
}
value, err := readString(&line)
if err != nil {
return fmt.Errorf("PREGAP: %w", err)
}
track.PreGap, err = frameFromString(value)
return err
}
func readTrackPostGap(track *Track, line string) error {
if track.PostGap > 0 {
return ErrorDuplicateTrackPostGap
}
value, err := readString(&line)
if err != nil {
return fmt.Errorf("POSTGAP: %w", err)
}
track.PostGap, err = frameFromString(value)
return err
}
func readTrackIndex(track *Track, line string) error {
index := TrackIndex{}
var err error
index.Number, err = readUint(&line)
if err != nil {
return fmt.Errorf("INDEX number: %w", err)
}
value, err := readString(&line)
if err != nil {
return fmt.Errorf("INDEX frame: %w", err)
}
index.Frame, err = frameFromString(value)
if err != nil {
return err
}
if len(track.Index) == 0 && index.Number > 1 {
return ErrorIndexOutOfOrder
} else if len(track.Index) > 0 && track.Index[len(track.Index)-1].Number != index.Number-1 {
return ErrorIndexOutOfOrder
}
track.Index = append(track.Index, index)
return nil
}
func readTrackRem(track *Track, line string) error {
if track.Rem == nil {
track.Rem = RemData{}
}
key, err := readString(&line)
if err != nil {
return fmt.Errorf("REM command key: %w", err)
}
trimmedLine := strings.TrimLeft(line, delims)
if len(trimmedLine) > 0 && isQuoted(trimmedLine) {
value, closed := unquote(trimmedLine)
if !closed {
return ErrorUnclosedQuote
}
track.Rem[key] = value
} else {
track.Rem[key] = line
}
return nil
}
func readTrackFields(track *Track, line string) error {
command, err := readString(&line)
if err != nil {
return fmt.Errorf("reading command: %w", err)
}
command = strings.ToUpper(command)
switch command {
case "FLAGS":
return readTrackFlags(track, line)
case "ISRC":
return readTrackISRC(track, line)
case "TITLE":
return readTrackStringField(&track.Title, line, ErrorDuplicateTrackTitle, "TITLE")
case "PERFORMER":
return readTrackStringField(&track.Performer, line, ErrorDuplicateTrackPerformer, "PERFORMER")
case "SONGWRITER":
return readTrackStringField(&track.SongWriter, line, ErrorDuplicateTrackSongwriter, "SONGWRITER")
case "PREGAP":
return readTrackPreGap(track, line)
case "POSTGAP":
return readTrackPostGap(track, line)
case "INDEX":
return readTrackIndex(track, line)
case "REM":
return readTrackRem(track, line)
default:
return nil
}
}
// ReadCue loads and parses CUESHEET from reader
func ReadCue(r io.Reader) (*Cuesheet, error) {
s := bufio.NewScanner(r)
cuesheet := &Cuesheet{}
firstLine := true
var fields ExpectedFields
trackJustInserted := false
for s.Scan() {
line := s.Text()
if firstLine {
firstLine = false
if !cueRegex.MatchString(line) {
return nil, ErrorParseCUE
}
}
if strings.HasPrefix(line, " ") {
line = line[4:]
fields = ExpectTrack
trackJustInserted = false
} else if strings.HasPrefix(line, " ") {
line = line[2:]
fields = ExpectTracks
if trackJustInserted {
return nil, ErrorExpectedTrackIndent
}
} else {
fields = ExpectCommon
if trackJustInserted {
return nil, ErrorExpectedTrackIndent
}
}
var err error
switch fields {
case ExpectCommon:
err = readCUEFields(cuesheet, line)
case ExpectTracks:
if len(cuesheet.File) == 0 {
return nil, ErrorMissingFile
}
trackJustInserted, err = readFileFields(&cuesheet.File[len(cuesheet.File)-1], line)
case ExpectTrack:
if len(cuesheet.File) == 0 {
return nil, ErrorMissingFile
}
tracks := cuesheet.File[len(cuesheet.File)-1].Tracks
if len(tracks) == 0 {
return nil, ErrorMissingTrack
}
err = readTrackFields(&tracks[len(tracks)-1], line)
}
if err != nil {
return nil, err
}
}
if len(cuesheet.File) == 0 {
return nil, ErrorMissingFile
}
for _, file := range cuesheet.File {
if len(file.Tracks) == 0 {
return nil, ErrorMissingTrack
}
for _, track := range file.Tracks {
if len(track.Index) == 0 || (len(track.Index) == 1 && track.Index[0].Number == 0) {
return nil, ErrorMissingIndex
}
}
}
return cuesheet, nil
}
func readString(s *string) (string, error) {
*s = strings.TrimLeft(*s, delims)
if len(*s) > 0 && isQuoted(*s) {
v, closed := unquote(*s)
if !closed {
return v, ErrorUnclosedQuote
}
*s = (*s)[len(v)+2:]
return v, nil
}
for i := 0; i < len(*s); i++ {
if (*s)[i] == ' ' {
v := (*s)[0:i]
*s = (*s)[i+1:]
return v, nil
}
}
v := *s
*s = ""
return v, nil
}
func readUint(s *string) (uint, error) {
v, err := readString(s)
if err != nil {
return 0, err
}
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid number: %w", err)
}
return uint(n), nil
}
func isQuoted(s string) bool {
if len(s) == 0 {
return false
}
return s[0] == '"' || s[0] == '\''
}
func unquote(s string) (string, bool) {
if len(s) == 0 || !isQuoted(s) {
return s, true
}
quote := s[0]
i := 1
for ; i < len(s); i++ {
if s[i] == quote {
return s[1:i], true
}
if s[i] == '\\' && i+1 < len(s) {
i++
}
}
return s[1:], false
}
func (rem *RemData) DiscNumber() int {
s, ok := (*rem)[remDiscNumber]
if ok {
result, err := strconv.Atoi(s)
if err != nil {
return 0
}
return result
}
return 0
}
func (rem *RemData) TotalDiscs() int {
s, ok := (*rem)[remTotalDiscs]
if ok {
result, err := strconv.Atoi(s)
if err != nil {
return 0
}
return result
}
return 0
}
// Genre returns genre from data
func (rem *RemData) Genre() string {
s, ok := (*rem)[remGenre]
if ok {
return s
}
return ""
}
// Comment returns comment field
func (rem *RemData) Comment() string {
s, ok := (*rem)[remComment]
if ok {
return s
}
return ""
}
// DiskID returns disk id from data
func (rem *RemData) DiskID() string {
s, ok := (*rem)[remDiskID]
if ok {
return s
}
return ""
}
// Date returns release year
func (rem *RemData) Date() string {
s, ok := (*rem)[remDate]
if ok {
return s
}
return ""
}
// AlbumGain returns album replay gain value
func (rem *RemData) AlbumGain() string {
s, ok := (*rem)[remRGAlbumGain]
if ok {
return s
}
return ""
}
// AlbumPeak returns album replay gain peak value
func (rem *RemData) AlbumPeak() string {
s, ok := (*rem)[remRGAlbumPeak]
if ok {
return s
}
return ""
}
// TrackGain returns track replay gain value
func (rem *RemData) TrackGain() string {
s, ok := (*rem)[remRGTrackGain]
if ok {
return s
}
return ""
}
// TrackPeak returns track replay gain peak value
func (rem *RemData) TrackPeak() string {
s, ok := (*rem)[remRGTrackPeak]
if ok {
return s
}
return ""
}

View File

@ -0,0 +1,347 @@
package cue
import (
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFrame_Duration(t *testing.T) {
tests := []struct {
input Frame
output time.Duration
}{
{
input: Frame(0),
output: time.Duration(0),
},
{
input: Frame(1),
output: time.Millisecond * 13,
},
{
input: Frame(74),
output: time.Millisecond * 986,
},
{
input: Frame(75),
output: time.Second,
},
{
input: Frame(76),
output: time.Second + time.Millisecond*13,
},
}
for _, test := range tests {
t.Run(test.input.String(), func(t *testing.T) {
require.Equal(t, test.output, test.input.Duration())
})
}
}
func TestFrameFromStringToString(t *testing.T) {
tests := []struct {
input string
output Frame
err error
}{
{
input: "14:15:70",
output: Frame(0xfac3),
},
{
input: "0:0:0",
err: ErrorFrameFormat,
},
{
input: "00:00:00",
output: Frame(0),
},
{
input: "00:00:10",
output: Frame(10),
},
{
input: "00:01:74",
output: Frame(0x95),
},
{
input: "155:01:01",
output: Frame(0xaa4e8),
},
{
input: "02:59:01",
output: Frame(0x3472),
},
{
input: "01:01:01",
output: Frame(0x11e0),
},
{
input: "00:01:75",
output: Frame(0),
err: ErrorFrameFormat,
},
{
input: "invalid",
output: Frame(0),
err: ErrorFrameFormat,
},
{
input: "001:60:73",
output: Frame(0),
err: ErrorFrameFormat,
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result, err := frameFromString(test.input)
if test.err != nil {
require.EqualError(t, err, test.err.Error())
} else {
require.NoError(t, err)
require.Equal(t, test.output, result)
require.Equal(t, test.input, result.String())
}
})
}
}
func TestCueReader(t *testing.T) {
tests := []struct {
inputFile string
check func(output *Cuesheet)
error error
}{
{
inputFile: "parser-case-insensitivity.cue",
check: func(output *Cuesheet) {
assert.NotEmpty(t, output.Performer)
},
},
{
inputFile: "parser-comments.cue",
check: func(output *Cuesheet) {
cueRem := output.Rem
assert.NotEmpty(t, cueRem)
assert.Contains(t, cueRem, "Foo")
assert.Contains(t, cueRem, "Bar")
assert.Contains(t, cueRem, "Yay!")
trackRem := output.File[0].Tracks[0].Rem
assert.NotEmpty(t, trackRem)
assert.Contains(t, trackRem, "Doox")
assert.Contains(t, trackRem, "Goox")
assert.Contains(t, trackRem, "Loox")
},
},
{
inputFile: "parser-wrong-track-indent.cue",
error: ErrorExpectedTrackIndent,
},
{
inputFile: "parser-duplicate-catalog.cue",
error: ErrorDuplicateCatalog,
},
{
inputFile: "parser-duplicate-cdtextfile.cue",
error: ErrorDuplicateCdTextFile,
},
{
inputFile: "parser-duplicate-performer.cue",
error: ErrorDuplicatePerformer,
},
{
inputFile: "parser-duplicate-songwriter.cue",
error: ErrorDuplicateSongwriter,
},
{
inputFile: "parser-duplicate-title.cue",
error: ErrorDuplicateTitle,
},
{
inputFile: "parser-duplicate-track-flags.cue",
error: ErrorDuplicateTrackFlags,
},
{
inputFile: "parser-duplicate-track-isrc.cue",
error: ErrorDuplicateTrackISRC,
},
{
inputFile: "parser-duplicate-track-performer.cue",
error: ErrorDuplicateTrackPerformer,
},
{
inputFile: "parser-duplicate-track-postgap.cue",
error: ErrorDuplicateTrackPostGap,
},
{
inputFile: "parser-duplicate-track-pregap.cue",
error: ErrorDuplicateTrackPreGap,
},
{
inputFile: "parser-duplicate-track-songwriter.cue",
error: ErrorDuplicateTrackSongwriter,
},
{
inputFile: "parser-duplicate-track-title.cue",
error: ErrorDuplicateTrackTitle,
},
{
inputFile: "parser-index-out-of-order.cue",
error: ErrorIndexOutOfOrder,
},
{
inputFile: "parser-invalid-catalog.cue",
error: ErrorInvalidCatalog,
},
{
inputFile: "parser-invalid-frames.cue",
error: ErrorFrameFormat,
},
{
inputFile: "parser-invalid-isrc.cue",
error: ErrorInvalidISRC,
},
{
inputFile: "parser-invalid-seconds.cue",
error: ErrorFrameFormat,
},
{
inputFile: "parser-invalid-text.cue",
error: ErrorInvalidText,
},
{
inputFile: "parser-missing-file.cue",
error: ErrorMissingFile,
},
{
inputFile: "parser-missing-index.cue",
error: ErrorMissingIndex,
},
{
inputFile: "parser-missing-track.cue",
error: ErrorMissingTrack,
},
{
inputFile: "parser-track-out-of-order.cue",
error: ErrorTrackOutOfOrder,
},
{
inputFile: "parser-track-out-of-order-2.cue",
error: ErrorTrackOutOfOrder,
},
{
inputFile: "test.cue",
check: func(output *Cuesheet) {
assert.Equal(t, "Into The Otherworld", output.Title)
assert.Equal(t, 1, len(output.File))
assert.Equal(t, 11, len(output.File[0].Tracks))
},
},
{
inputFile: "test_1.cue",
check: func(output *Cuesheet) {
assert.Equal(t, "When All Is Said: The Best of Edge of Sanity", output.Title)
assert.Equal(t, 1, len(output.File))
assert.Equal(t, 99, len(output.File[0].Tracks))
},
},
{
inputFile: "invalid.binary",
error: ErrorParseCUE,
},
// Recoverable errors
{
inputFile: "parser-unclosed-quote-performer.cue",
check: func(output *Cuesheet) {
assert.Equal(t, "Pink Floyd", output.Performer)
assert.Equal(t, "The Dark Side of the Moon", output.Title)
},
},
{
inputFile: "parser-unclosed-quote-title.cue",
check: func(output *Cuesheet) {
assert.Equal(t, "Greatest Hits", output.Title)
assert.Equal(t, "Various Artists", output.Performer)
},
},
{
inputFile: "parser-unclosed-quote-songwriter.cue",
check: func(output *Cuesheet) {
assert.Equal(t, "John Lennon", output.SongWriter)
assert.Equal(t, "Artist Name", output.Performer)
},
},
{
inputFile: "parser-unclosed-quote-track-title.cue",
check: func(output *Cuesheet) {
assert.Equal(t, 1, len(output.File[0].Tracks))
assert.Equal(t, "Song Title Without Closing Quote", output.File[0].Tracks[0].Title)
},
},
{
inputFile: "parser-unclosed-quote-track-performer.cue",
check: func(output *Cuesheet) {
assert.Equal(t, 1, len(output.File[0].Tracks))
assert.Equal(t, "Track Artist", output.File[0].Tracks[0].Performer)
},
},
{
inputFile: "parser-unclosed-quote-track-songwriter.cue",
check: func(output *Cuesheet) {
assert.Equal(t, 1, len(output.File[0].Tracks))
assert.Equal(t, "Paul McCartney", output.File[0].Tracks[0].SongWriter)
},
},
{
inputFile: "parser-unclosed-quote-cdtextfile.cue",
check: func(output *Cuesheet) {
assert.Equal(t, "cdtext.cdt", output.CdTextFile)
assert.Equal(t, "Artist", output.Performer)
},
},
// Unrecoverable errors
{
inputFile: "parser-unclosed-quote-file.cue",
error: ErrorUnclosedQuote,
},
{
inputFile: "parser-unclosed-quote-file-type.cue",
error: ErrorUnclosedQuote,
},
{
inputFile: "parser-unclosed-quote-rem-key.cue",
error: ErrorUnclosedQuote,
},
{
inputFile: "parser-unclosed-quote-rem-value.cue",
error: ErrorUnclosedQuote,
},
// Special cases
{
inputFile: "parser-unclosed-quote-escaped-end.cue",
check: func(output *Cuesheet) {
assert.Equal(t, `Artist Name\"`, output.Performer)
},
},
}
for _, test := range tests {
t.Run(test.inputFile, func(t *testing.T) {
cue, err := ReadFromFile(filepath.Join("testdata", test.inputFile))
if test.error != nil {
assert.ErrorContains(t, err, test.error.Error())
} else {
assert.NoError(t, err)
assert.NotNil(t, cue)
assert.NotNil(t, test.check)
test.check(cue)
}
})
}
}

View File

@ -0,0 +1,110 @@
package cue
import (
"fmt"
"path"
"strconv"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
)
type TagsExtractor struct {
md *metadata.Metadata
}
func NewExtractor(md *metadata.Metadata) (*TagsExtractor, error) {
return &TagsExtractor{
md: md,
}, nil
}
func makeDuration(d time.Duration) string {
return strconv.FormatFloat(d.Seconds(), 'f', 3, 32)
}
func (e *TagsExtractor) Extract(cueSheet *Cuesheet, cueFile string) ([]model.RawTags, error) {
result := make([]model.RawTags, 0)
for _, file := range cueSheet.File {
if cueFile != "" && file.FileName != path.Base(e.md.FilePath()) {
continue
}
totalTracks := len(file.Tracks)
lastTrack := totalTracks - 1
for i, t := range file.Tracks {
tags := make(model.RawTags)
addTagValue(model.TagCUEFile, tags, cueFile)
addTagValue(model.TagTitle, tags, t.Title)
addTagValue(model.TagCUESubTrack, tags, strconv.Itoa(i))
addTagValue(model.TagTrackArtist, tags, t.Performer, cueSheet.Performer)
addTagValue(model.TagAlbum, tags, cueSheet.Title)
addTagValue(model.TagGenre, tags, cueSheet.Rem.Genre())
if t.Rem.TotalDiscs() > 0 {
if t.Rem.DiscNumber() > 0 {
addTagValue(model.TagDiscNumber, tags, strconv.FormatUint(uint64(t.Rem.DiscNumber()), 10))
}
addTagValue(model.TagTotalDiscs, tags, strconv.FormatUint(uint64(t.Rem.TotalDiscs()), 10))
}
addTagValue(model.TagAlbumArtist, tags, cueSheet.Performer)
addTagValue(model.TagComment, tags, t.Rem.Comment(), cueSheet.Rem.Comment())
addTagValue(model.TagTrackNumber, tags, strconv.FormatUint(uint64(t.TrackNumber), 10))
addTagValue(model.TagTotalTracks, tags, strconv.FormatUint(uint64(totalTracks), 10))
addTagValue(model.TagReleaseDate, tags, cueSheet.Rem.Date())
addTagValue(model.TagReplayGainAlbumGain, tags, cueSheet.Rem.AlbumGain())
addTagValue(model.TagReplayGainAlbumPeak, tags, cueSheet.Rem.AlbumPeak())
addTagValue(model.TagReplayGainTrackGain, tags, t.Rem.TrackGain())
addTagValue(model.TagReplayGainTrackPeak, tags, t.Rem.TrackPeak())
addTagValue(model.TagCUETrackOffset, tags, makeDuration(t.GetStartOffset()))
addTagValue(model.TagISRC, tags, t.ISRC)
var length time.Duration
if i < lastTrack {
nextTrack := file.Tracks[i+1]
length = nextTrack.GetStartOffset() - t.GetStartOffset()
} else {
fileDuration := time.Millisecond * time.Duration(e.md.Length()*1000.0)
length = fileDuration - t.GetStartOffset()
}
if length < 0 {
length = 0
}
addTagValue(model.TagCUETrackDuration, tags, makeDuration(length))
// Fallback to main metadata for missing tags
addTagValue(model.TagAlbum, tags, e.md.String(model.TagAlbum))
addTagValue(model.TagAlbumArtist, tags, e.md.String(model.TagAlbumArtist))
addTagValue(model.TagReleaseDate, tags, e.md.String(model.TagReleaseDate))
for _, genre := range e.md.Strings(model.TagGenre) {
addTagValue(model.TagGenre, tags, genre)
}
addTagValue(model.TagMusicBrainzAlbumID, tags, e.md.String(model.TagMusicBrainzAlbumID))
addTagValue(model.TagMusicBrainzArtistID, tags, e.md.String(model.TagMusicBrainzArtistID))
addTagValue(model.TagMusicBrainzRecordingID, tags, e.md.String(model.TagMusicBrainzRecordingID))
addTagValue(model.TagMusicBrainzTrackID, tags, e.md.String(model.TagMusicBrainzTrackID))
for _, value := range e.md.CueTags()[fmt.Sprintf("cue_track%02d_musicbrainz_trackid", t.TrackNumber)] {
addTagValue(model.TagMusicBrainzTrackID, tags, value)
}
disc, discs := e.md.NumAndTotal(model.TagDiscNumber)
if disc > 0 && discs > 0 {
addTagValue(model.TagDiscNumber, tags, strconv.FormatUint(uint64(disc), 10))
addTagValue(model.TagTotalDiscs, tags, strconv.FormatUint(uint64(discs), 10))
}
result = append(result, tags)
}
}
return result, nil
}
func addTagValue(name model.TagName, tags model.RawTags, values ...string) {
for _, v := range values {
if v != "" {
tags[name.String()] = append(tags[name.String()], v)
return
}
}
}

Binary file not shown.

View File

@ -0,0 +1,9 @@
perFORmer "Faithless"
rem GENRE Electronica
title "Live in Berlin"
file "Faithless - Live in Berlin.mp3" MP3
TRack 01 AUDIO
TITLE "Reverence"
performer "Faithless"
Index 01 00:00:03
inDex 02 00:02:01

View File

@ -0,0 +1,24 @@
REM Foo
REM Foo
PERFORMER "Faithless"
REM Bar
REM Bar
TITLE "Live in Berlin"
REM Baz
REM Baz
FILE "Faithless - Live in Berlin.mp3" MP3
REM Quux
REM Quux
TRACK 01 AUDIO
TITLE "Reverence"
REM Doox
REM Doox
PERFORMER "Faithless"
REM Loox
REM Loox
INDEX 01 00:00:03
REM Goox
REM Goox
INDEX 02 00:02:01
REM Yay!
REM Yay!

View File

@ -0,0 +1,2 @@
CATALOG 0112223334445
CATALOG 0112223334442

View File

@ -0,0 +1,2 @@
CDTEXTFILE my-file.txt
CDTEXTFILE my-file.txt

View File

@ -0,0 +1,2 @@
PERFORMER "Captain Beefheart"
PERFORMER "Captain Beefheart"

View File

@ -0,0 +1,2 @@
SONGWRITER Foobar
SONGWRITER Foobar

View File

@ -0,0 +1,2 @@
TITLE "My title"
TITLE "My title"

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
FLAGS 4CH
FLAGS 4CH
PERFORMER "Faithless"
INDEX 02 00:00:01

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
ISRC aaabb1231231
ISRC aaabb1231231
PERFORMER "Faithless"
INDEX 02 00:00:01

View File

@ -0,0 +1,8 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
PERFORMER "Faithless"
INDEX 02 00:00:01

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
INDEX 01 00:00:01
POSTGAP 00:01:00
POSTGAP 00:01:00

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
PREGAP 00:01:00
PREGAP 00:01:00
INDEX 02 00:00:01

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
SONGWRITER Boba
SONGWRITER Boba
PERFORMER "Faithless"
INDEX 02 00:00:01

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
TITLE "Foo"
TITLE "Foo"
PERFORMER "Faithless"
INDEX 02 00:00:01

View File

@ -0,0 +1,7 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
INDEX 02 00:00:01

View File

@ -0,0 +1 @@
CATALOG 123

View File

@ -0,0 +1,7 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
INDEX 01 00:00:77

View File

@ -0,0 +1,8 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
ISRC 123
INDEX 01 00:00:00

View File

@ -0,0 +1,7 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
INDEX 01 00:61:00

View File

@ -0,0 +1 @@
PERFORMER ""

View File

@ -0,0 +1,3 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"

View File

@ -0,0 +1,7 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
INDEX 00 00:00:00

View File

@ -0,0 +1,4 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 01 AUDIO
PERFORMER "Faithless"
INDEX 01 00:00:00
TRACK 03 AUDIO
PERFORMER "Faithless"

View File

@ -0,0 +1,9 @@
PERFORMER "Faithless"
REM GENRE Electronica
TITLE "Live in Berlin"
FILE "Faithless - Live in Berlin.mp3" MP3
TRACK 03 AUDIO
PERFORMER "Faithless"
INDEX 01 00:00:00
TRACK 02 AUDIO
PERFORMER "Faithless"

View File

@ -0,0 +1,8 @@
REM Testing unclosed quote in CDTEXTFILE
CDTEXTFILE "cdtext.cdt
PERFORMER "Artist"
TITLE "Album"
FILE "audio.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,7 @@
REM Testing unclosed quote with escaped backslash at end
PERFORMER "Artist Name\"
TITLE "Album"
FILE "audio.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,8 @@
REM Testing FILE with both filename and type in one quoted string (edge case)
PERFORMER "Artist"
TITLE "Album"
FILE "file.flac" WAVE
FILE "file2.flac" "WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,7 @@
REM Testing unclosed quote in FILE name - CRITICAL: type expected after
PERFORMER "Artist"
TITLE "Album"
FILE "path/to/audio/file.flac WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,7 @@
REM Testing unclosed quote in album PERFORMER
PERFORMER "Pink Floyd
TITLE "The Dark Side of the Moon"
FILE "dsotm.flac" WAVE
TRACK 01 AUDIO
TITLE "Speak to Me"
INDEX 01 00:00:00

View File

@ -0,0 +1,8 @@
REM Testing unclosed quote in REM key
PERFORMER "Artist"
TITLE "Album"
REM "SPECIAL_KEY value here
FILE "audio.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,8 @@
REM Testing unclosed quote in REM value (after key)
PERFORMER "Artist"
TITLE "Album"
REM GENRE "Progressive Rock
FILE "audio.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,8 @@
REM Testing unclosed quote in SONGWRITER
PERFORMER "Artist Name"
TITLE "Album Title"
SONGWRITER "John Lennon
FILE "album.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,7 @@
REM Testing unclosed quote in album TITLE - end of line
PERFORMER "Various Artists"
TITLE "Greatest Hits
FILE "album.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
INDEX 01 00:00:00

View File

@ -0,0 +1,8 @@
REM Testing unclosed quote in track PERFORMER
PERFORMER "Album Artist"
TITLE "Album"
FILE "audio.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
PERFORMER "Track Artist
INDEX 01 00:00:00

View File

@ -0,0 +1,8 @@
REM Testing unclosed quote in track SONGWRITER
PERFORMER "Album Artist"
TITLE "Album"
FILE "audio.flac" WAVE
TRACK 01 AUDIO
TITLE "Track One"
SONGWRITER "Paul McCartney
INDEX 01 00:00:00

View File

@ -0,0 +1,7 @@
REM Testing unclosed quote in track TITLE
PERFORMER "Artist"
TITLE "Album"
FILE "audio.flac" WAVE
TRACK 01 AUDIO
TITLE "Song Title Without Closing Quote
INDEX 01 00:00:00

View File

@ -0,0 +1,26 @@
REM Foo
REM Foo
PERFORMER "Faithless"
REM Bar
REM Bar
TITLE "Live in Berlin"
REM Baz
REM Baz
FILE "Faithless - Live in Berlin.mp3" MP3
REM Quux
REM Quux
TRACK 01 AUDIO
REM Fuux
REM Fuux
TITLE "Reverence"
REM Doox
REM Doox
PERFORMER "Faithless"
REM Loox
REM Loox
INDEX 01 00:00:03
REM Goox
REM Goox
INDEX 02 00:02:01
REM Yay!
REM Yay!

82
model/metadata/cue/testdata/test.cue vendored Normal file
View File

@ -0,0 +1,82 @@
REM GENRE Folk
REM DATE 2005
REM DISCID 9A0D690B
REM COMMENT ExactAudioCopy v0.99pb1
PERFORMER "Thanateros"
TITLE "Into The Otherworld"
REM REPLAYGAIN_ALBUM_GAIN -10.33 dB
REM REPLAYGAIN_ALBUM_PEAK 1.000000
FILE "Thanateros - Into The Otherworld.flac" WAVE
TRACK 01 AUDIO
TITLE "Calling Ilyr"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -10.21 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Follow"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -11.18 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 00 04:11:53
INDEX 01 04:13:36
TRACK 03 AUDIO
TITLE "Siuil A Run"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -11.52 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 00 08:00:38
INDEX 01 08:02:31
TRACK 04 AUDIO
TITLE "Maggy"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -9.65 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 01 11:22:59
TRACK 05 AUDIO
TITLE "I'm Changing"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -9.33 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 00 14:15:11
INDEX 01 14:15:70
TRACK 06 AUDIO
TITLE "Song As A Kiss"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -8.34 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 01 20:46:23
TRACK 07 AUDIO
TITLE "No Rest"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -11.37 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 00 24:15:56
INDEX 01 24:17:61
TRACK 08 AUDIO
TITLE "Dirty Old Town"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -10.82 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 01 28:31:15
TRACK 09 AUDIO
TITLE "In Time"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -10.97 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 00 32:37:52
INDEX 01 32:40:01
TRACK 10 AUDIO
TITLE "Four Winds"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -9.76 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 00 36:46:67
INDEX 01 36:49:17
TRACK 11 AUDIO
TITLE "Привет"
ISRC 000000000000
REM REPLAYGAIN_TRACK_GAIN -9.91 dB
REM REPLAYGAIN_TRACK_PEAK 1.000000
INDEX 00 42:12:17
INDEX 01 42:14:74

505
model/metadata/cue/testdata/test_1.cue vendored Normal file
View File

@ -0,0 +1,505 @@
REM GENRE Death Metal
REM DATE 2006
REM DISCID E912C263
REM COMMENT ExactAudioCopy v1.0b1
PERFORMER "Edge of Sanity"
TITLE "When All Is Said: The Best of Edge of Sanity"
REM REPLAYGAIN_ALBUM_GAIN -8.32 dB
REM REPLAYGAIN_ALBUM_PEAK 1.150353
FILE "Edge of Sanity - When All Is Said (CD2).flac" WAVE
TRACK 01 AUDIO
TITLE "Crimson, Part 1"
REM REPLAYGAIN_TRACK_GAIN -10.16 dB
REM REPLAYGAIN_TRACK_PEAK 1.117337
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Crimson, Part 2"
REM REPLAYGAIN_TRACK_GAIN -9.38 dB
REM REPLAYGAIN_TRACK_PEAK 1.082512
INDEX 01 00:38:16
TRACK 03 AUDIO
TITLE "Crimson, Part 3"
REM REPLAYGAIN_TRACK_GAIN -11.02 dB
REM REPLAYGAIN_TRACK_PEAK 1.114110
INDEX 01 01:55:15
TRACK 04 AUDIO
TITLE "Crimson, Part 4"
REM REPLAYGAIN_TRACK_GAIN -10.59 dB
REM REPLAYGAIN_TRACK_PEAK 1.082862
INDEX 01 02:42:11
TRACK 05 AUDIO
TITLE "Crimson, Part 5"
REM REPLAYGAIN_TRACK_GAIN -4.90 dB
REM REPLAYGAIN_TRACK_PEAK 1.032541
INDEX 01 03:09:01
TRACK 06 AUDIO
TITLE "Crimson, Part 6"
REM REPLAYGAIN_TRACK_GAIN -3.74 dB
REM REPLAYGAIN_TRACK_PEAK 1.025570
INDEX 01 04:02:15
TRACK 07 AUDIO
TITLE "Crimson, Part 7"
REM REPLAYGAIN_TRACK_GAIN -9.83 dB
REM REPLAYGAIN_TRACK_PEAK 1.069188
INDEX 01 04:54:07
TRACK 08 AUDIO
TITLE "Crimson, Part 8"
REM REPLAYGAIN_TRACK_GAIN -10.75 dB
REM REPLAYGAIN_TRACK_PEAK 1.078141
INDEX 01 05:13:71
TRACK 09 AUDIO
TITLE "Crimson, Part 9"
REM REPLAYGAIN_TRACK_GAIN -10.00 dB
REM REPLAYGAIN_TRACK_PEAK 1.081721
INDEX 01 05:44:56
TRACK 10 AUDIO
TITLE "Crimson, Part 10"
REM REPLAYGAIN_TRACK_GAIN -11.05 dB
REM REPLAYGAIN_TRACK_PEAK 1.061313
INDEX 01 06:08:50
TRACK 11 AUDIO
TITLE "Crimson, Part 11"
REM REPLAYGAIN_TRACK_GAIN -9.83 dB
REM REPLAYGAIN_TRACK_PEAK 1.058353
INDEX 01 06:40:64
TRACK 12 AUDIO
TITLE "Crimson, Part 12"
REM REPLAYGAIN_TRACK_GAIN -9.66 dB
REM REPLAYGAIN_TRACK_PEAK 1.049014
INDEX 01 07:46:36
TRACK 13 AUDIO
TITLE "Crimson, Part 13"
REM REPLAYGAIN_TRACK_GAIN -10.96 dB
REM REPLAYGAIN_TRACK_PEAK 1.080913
INDEX 01 08:19:59
TRACK 14 AUDIO
TITLE "Crimson, Part 14"
REM REPLAYGAIN_TRACK_GAIN -9.41 dB
REM REPLAYGAIN_TRACK_PEAK 1.063654
INDEX 01 09:17:06
TRACK 15 AUDIO
TITLE "Crimson, Part 15"
REM REPLAYGAIN_TRACK_GAIN -10.16 dB
REM REPLAYGAIN_TRACK_PEAK 1.050239
INDEX 01 10:45:65
TRACK 16 AUDIO
TITLE "Crimson, Part 16"
REM REPLAYGAIN_TRACK_GAIN -9.77 dB
REM REPLAYGAIN_TRACK_PEAK 1.103854
INDEX 01 11:06:35
TRACK 17 AUDIO
TITLE "Crimson, Part 17"
REM REPLAYGAIN_TRACK_GAIN -9.96 dB
REM REPLAYGAIN_TRACK_PEAK 1.099316
INDEX 01 11:47:15
TRACK 18 AUDIO
TITLE "Crimson, Part 18"
REM REPLAYGAIN_TRACK_GAIN -9.34 dB
REM REPLAYGAIN_TRACK_PEAK 1.093543
INDEX 01 12:11:52
TRACK 19 AUDIO
TITLE "Crimson, Part 19"
REM REPLAYGAIN_TRACK_GAIN -10.63 dB
REM REPLAYGAIN_TRACK_PEAK 1.090411
INDEX 01 12:41:62
TRACK 20 AUDIO
TITLE "Crimson, Part 20"
REM REPLAYGAIN_TRACK_GAIN -9.76 dB
REM REPLAYGAIN_TRACK_PEAK 1.073009
INDEX 01 12:59:27
TRACK 21 AUDIO
TITLE "Crimson, Part 21"
REM REPLAYGAIN_TRACK_GAIN -10.51 dB
REM REPLAYGAIN_TRACK_PEAK 1.075462
INDEX 01 14:04:09
TRACK 22 AUDIO
TITLE "Crimson, Part 22"
REM REPLAYGAIN_TRACK_GAIN -9.60 dB
REM REPLAYGAIN_TRACK_PEAK 1.081226
INDEX 01 14:44:25
TRACK 23 AUDIO
TITLE "Crimson, Part 23"
REM REPLAYGAIN_TRACK_GAIN -9.60 dB
REM REPLAYGAIN_TRACK_PEAK 1.102361
INDEX 01 15:33:20
TRACK 24 AUDIO
TITLE "Crimson, Part 24"
REM REPLAYGAIN_TRACK_GAIN -7.61 dB
REM REPLAYGAIN_TRACK_PEAK 1.091328
INDEX 01 16:31:58
TRACK 25 AUDIO
TITLE "Crimson, Part 25"
REM REPLAYGAIN_TRACK_GAIN -9.62 dB
REM REPLAYGAIN_TRACK_PEAK 1.080595
INDEX 01 17:45:49
TRACK 26 AUDIO
TITLE "Crimson, Part 26"
REM REPLAYGAIN_TRACK_GAIN -8.70 dB
REM REPLAYGAIN_TRACK_PEAK 1.097867
INDEX 01 18:16:61
TRACK 27 AUDIO
TITLE "Crimson, Part 27"
REM REPLAYGAIN_TRACK_GAIN -10.71 dB
REM REPLAYGAIN_TRACK_PEAK 1.089200
INDEX 01 19:12:50
TRACK 28 AUDIO
TITLE "Crimson, Part 28"
REM REPLAYGAIN_TRACK_GAIN -9.87 dB
REM REPLAYGAIN_TRACK_PEAK 1.090685
INDEX 01 19:59:58
TRACK 29 AUDIO
TITLE "Crimson, Part 29"
REM REPLAYGAIN_TRACK_GAIN -10.75 dB
REM REPLAYGAIN_TRACK_PEAK 1.124153
INDEX 01 20:44:19
TRACK 30 AUDIO
TITLE "Crimson, Part 30"
REM REPLAYGAIN_TRACK_GAIN -9.85 dB
REM REPLAYGAIN_TRACK_PEAK 1.087260
INDEX 01 21:40:05
TRACK 31 AUDIO
TITLE "Crimson, Part 31"
REM REPLAYGAIN_TRACK_GAIN -10.44 dB
REM REPLAYGAIN_TRACK_PEAK 1.080998
INDEX 01 22:52:63
TRACK 32 AUDIO
TITLE "Crimson, Part 32"
REM REPLAYGAIN_TRACK_GAIN -9.25 dB
REM REPLAYGAIN_TRACK_PEAK 1.084413
INDEX 01 24:27:57
TRACK 33 AUDIO
TITLE "Crimson, Part 33"
REM REPLAYGAIN_TRACK_GAIN -11.27 dB
REM REPLAYGAIN_TRACK_PEAK 1.089645
INDEX 01 25:43:28
TRACK 34 AUDIO
TITLE "Crimson, Part 34"
REM REPLAYGAIN_TRACK_GAIN -6.95 dB
REM REPLAYGAIN_TRACK_PEAK 1.073506
INDEX 01 26:40:35
TRACK 35 AUDIO
TITLE "Crimson, Part 35"
REM REPLAYGAIN_TRACK_GAIN -10.39 dB
REM REPLAYGAIN_TRACK_PEAK 1.102312
INDEX 01 27:51:54
TRACK 36 AUDIO
TITLE "Crimson, Part 36"
REM REPLAYGAIN_TRACK_GAIN -9.35 dB
REM REPLAYGAIN_TRACK_PEAK 1.065695
INDEX 01 29:14:55
TRACK 37 AUDIO
TITLE "Crimson, Part 37"
REM REPLAYGAIN_TRACK_GAIN -9.79 dB
REM REPLAYGAIN_TRACK_PEAK 1.074667
INDEX 01 30:05:16
TRACK 38 AUDIO
TITLE "Crimson, Part 38"
REM REPLAYGAIN_TRACK_GAIN -10.20 dB
REM REPLAYGAIN_TRACK_PEAK 1.072641
INDEX 01 31:06:70
TRACK 39 AUDIO
TITLE "Crimson, Part 39"
REM REPLAYGAIN_TRACK_GAIN -3.66 dB
REM REPLAYGAIN_TRACK_PEAK 0.813600
INDEX 01 31:39:38
TRACK 40 AUDIO
TITLE "Crimson, Part 40"
REM REPLAYGAIN_TRACK_GAIN -10.18 dB
REM REPLAYGAIN_TRACK_PEAK 1.095581
INDEX 01 32:27:00
TRACK 41 AUDIO
TITLE "Crimson, Part 41"
REM REPLAYGAIN_TRACK_GAIN -10.26 dB
REM REPLAYGAIN_TRACK_PEAK 1.096531
INDEX 01 33:20:11
TRACK 42 AUDIO
TITLE "Crimson, Part 42"
REM REPLAYGAIN_TRACK_GAIN -10.22 dB
REM REPLAYGAIN_TRACK_PEAK 1.099213
INDEX 01 34:18:19
TRACK 43 AUDIO
TITLE "Crimson, Part 43"
REM REPLAYGAIN_TRACK_GAIN -10.09 dB
REM REPLAYGAIN_TRACK_PEAK 1.091079
INDEX 01 35:54:45
TRACK 44 AUDIO
TITLE "Crimson, Part 44"
REM REPLAYGAIN_TRACK_GAIN -9.66 dB
REM REPLAYGAIN_TRACK_PEAK 1.067710
INDEX 01 37:32:16
TRACK 45 AUDIO
TITLE "Crimson, Part 45"
REM REPLAYGAIN_TRACK_GAIN -9.82 dB
REM REPLAYGAIN_TRACK_PEAK 1.078692
INDEX 01 37:52:12
TRACK 46 AUDIO
TITLE "Crimson, Part 46"
REM REPLAYGAIN_TRACK_GAIN -10.10 dB
REM REPLAYGAIN_TRACK_PEAK 1.061775
INDEX 01 38:13:64
TRACK 47 AUDIO
TITLE "Crimson II, Part 1"
REM REPLAYGAIN_TRACK_GAIN -10.84 dB
REM REPLAYGAIN_TRACK_PEAK 1.053018
INDEX 00 38:36:29
INDEX 01 38:38:29
TRACK 48 AUDIO
TITLE "Crimson II, Part 2"
REM REPLAYGAIN_TRACK_GAIN -10.44 dB
REM REPLAYGAIN_TRACK_PEAK 1.069183
INDEX 01 39:56:22
TRACK 49 AUDIO
TITLE "Crimson II, Part 3"
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
REM REPLAYGAIN_TRACK_PEAK 1.042545
INDEX 01 41:00:31
TRACK 50 AUDIO
TITLE "Crimson II, Part 4"
REM REPLAYGAIN_TRACK_GAIN -10.33 dB
REM REPLAYGAIN_TRACK_PEAK 1.039090
INDEX 01 41:43:16
TRACK 51 AUDIO
TITLE "Crimson II, Part 5"
REM REPLAYGAIN_TRACK_GAIN -9.77 dB
REM REPLAYGAIN_TRACK_PEAK 1.076504
INDEX 01 42:47:19
TRACK 52 AUDIO
TITLE "Crimson II, Part 6"
REM REPLAYGAIN_TRACK_GAIN -10.00 dB
REM REPLAYGAIN_TRACK_PEAK 1.066017
INDEX 01 43:47:08
TRACK 53 AUDIO
TITLE "Crimson II, Part 7"
REM REPLAYGAIN_TRACK_GAIN -10.48 dB
REM REPLAYGAIN_TRACK_PEAK 1.053724
INDEX 01 44:34:55
TRACK 54 AUDIO
TITLE "Crimson II, Part 8"
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
REM REPLAYGAIN_TRACK_PEAK 1.059979
INDEX 01 45:47:35
TRACK 55 AUDIO
TITLE "Crimson II, Part 9"
REM REPLAYGAIN_TRACK_GAIN -10.24 dB
REM REPLAYGAIN_TRACK_PEAK 1.043081
INDEX 01 46:52:41
TRACK 56 AUDIO
TITLE "Crimson II, Part 10"
REM REPLAYGAIN_TRACK_GAIN -10.53 dB
REM REPLAYGAIN_TRACK_PEAK 1.059817
INDEX 01 47:32:38
TRACK 57 AUDIO
TITLE "Crimson II, Part 11"
REM REPLAYGAIN_TRACK_GAIN -10.35 dB
REM REPLAYGAIN_TRACK_PEAK 1.052755
INDEX 01 48:39:01
TRACK 58 AUDIO
TITLE "Crimson II, Part 12"
REM REPLAYGAIN_TRACK_GAIN -10.17 dB
REM REPLAYGAIN_TRACK_PEAK 1.039192
INDEX 01 49:27:22
TRACK 59 AUDIO
TITLE "Crimson II, Part 13"
REM REPLAYGAIN_TRACK_GAIN -8.15 dB
REM REPLAYGAIN_TRACK_PEAK 1.029701
INDEX 01 50:11:11
TRACK 60 AUDIO
TITLE "Crimson II, Part 14"
REM REPLAYGAIN_TRACK_GAIN -10.18 dB
REM REPLAYGAIN_TRACK_PEAK 1.084152
INDEX 01 51:23:51
TRACK 61 AUDIO
TITLE "Crimson II, Part 15"
REM REPLAYGAIN_TRACK_GAIN -10.29 dB
REM REPLAYGAIN_TRACK_PEAK 1.064918
INDEX 01 51:59:19
TRACK 62 AUDIO
TITLE "Crimson II, Part 16"
REM REPLAYGAIN_TRACK_GAIN -10.44 dB
REM REPLAYGAIN_TRACK_PEAK 1.039924
INDEX 01 52:57:70
TRACK 63 AUDIO
TITLE "Crimson II, Part 17"
REM REPLAYGAIN_TRACK_GAIN -10.37 dB
REM REPLAYGAIN_TRACK_PEAK 1.065348
INDEX 01 53:41:13
TRACK 64 AUDIO
TITLE "Crimson II, Part 18"
REM REPLAYGAIN_TRACK_GAIN -9.58 dB
REM REPLAYGAIN_TRACK_PEAK 1.055689
INDEX 01 54:35:73
TRACK 65 AUDIO
TITLE "Crimson II, Part 19"
REM REPLAYGAIN_TRACK_GAIN -10.20 dB
REM REPLAYGAIN_TRACK_PEAK 1.043689
INDEX 01 55:47:60
TRACK 66 AUDIO
TITLE "Crimson II, Part 20"
REM REPLAYGAIN_TRACK_GAIN -10.79 dB
REM REPLAYGAIN_TRACK_PEAK 1.054136
INDEX 01 56:52:64
TRACK 67 AUDIO
TITLE "Crimson II, Part 21"
REM REPLAYGAIN_TRACK_GAIN -10.55 dB
REM REPLAYGAIN_TRACK_PEAK 1.031386
INDEX 01 58:09:48
TRACK 68 AUDIO
TITLE "Crimson II, Part 22"
REM REPLAYGAIN_TRACK_GAIN -2.30 dB
REM REPLAYGAIN_TRACK_PEAK 1.018072
INDEX 01 58:28:56
TRACK 69 AUDIO
TITLE "Crimson II, Part 23"
REM REPLAYGAIN_TRACK_GAIN -10.04 dB
REM REPLAYGAIN_TRACK_PEAK 1.056946
INDEX 01 59:05:32
TRACK 70 AUDIO
TITLE "Crimson II, Part 24"
REM REPLAYGAIN_TRACK_GAIN -10.40 dB
REM REPLAYGAIN_TRACK_PEAK 1.050760
INDEX 01 59:17:71
TRACK 71 AUDIO
TITLE "Crimson II, Part 25"
REM REPLAYGAIN_TRACK_GAIN -10.41 dB
REM REPLAYGAIN_TRACK_PEAK 1.039001
INDEX 01 59:58:08
TRACK 72 AUDIO
TITLE "Crimson II, Part 26"
REM REPLAYGAIN_TRACK_GAIN -9.82 dB
REM REPLAYGAIN_TRACK_PEAK 1.057534
INDEX 01 60:20:56
TRACK 73 AUDIO
TITLE "Crimson II, Part 27"
REM REPLAYGAIN_TRACK_GAIN +0.03 dB
REM REPLAYGAIN_TRACK_PEAK 0.535926
INDEX 01 61:13:25
TRACK 74 AUDIO
TITLE "Crimson II, Part 28"
REM REPLAYGAIN_TRACK_GAIN -10.84 dB
REM REPLAYGAIN_TRACK_PEAK 1.053493
INDEX 01 61:33:62
TRACK 75 AUDIO
TITLE "Crimson II, Part 29"
REM REPLAYGAIN_TRACK_GAIN -8.94 dB
REM REPLAYGAIN_TRACK_PEAK 1.034394
INDEX 01 62:22:38
TRACK 76 AUDIO
TITLE "Crimson II, Part 30"
REM REPLAYGAIN_TRACK_GAIN -5.36 dB
REM REPLAYGAIN_TRACK_PEAK 0.981271
INDEX 01 62:49:03
TRACK 77 AUDIO
TITLE "Crimson II, Part 31"
REM REPLAYGAIN_TRACK_GAIN -6.23 dB
REM REPLAYGAIN_TRACK_PEAK 1.002305
INDEX 01 63:35:61
TRACK 78 AUDIO
TITLE "Crimson II, Part 32"
REM REPLAYGAIN_TRACK_GAIN -10.40 dB
REM REPLAYGAIN_TRACK_PEAK 1.057299
INDEX 01 64:45:09
TRACK 79 AUDIO
TITLE "Crimson II, Part 33"
REM REPLAYGAIN_TRACK_GAIN -10.62 dB
REM REPLAYGAIN_TRACK_PEAK 1.036386
INDEX 01 65:02:34
TRACK 80 AUDIO
TITLE "Crimson II, Part 34"
REM REPLAYGAIN_TRACK_GAIN -10.51 dB
REM REPLAYGAIN_TRACK_PEAK 1.048394
INDEX 01 65:18:29
TRACK 81 AUDIO
TITLE "Crimson II, Part 35"
REM REPLAYGAIN_TRACK_GAIN -7.50 dB
REM REPLAYGAIN_TRACK_PEAK 1.013283
INDEX 01 66:30:30
TRACK 82 AUDIO
TITLE "Crimson II, Part 36"
REM REPLAYGAIN_TRACK_GAIN -7.71 dB
REM REPLAYGAIN_TRACK_PEAK 1.016238
INDEX 01 67:14:40
TRACK 83 AUDIO
TITLE "Crimson II, Part 37"
REM REPLAYGAIN_TRACK_GAIN -9.34 dB
REM REPLAYGAIN_TRACK_PEAK 1.069951
INDEX 01 68:00:63
TRACK 84 AUDIO
TITLE "Crimson II, Part 38"
REM REPLAYGAIN_TRACK_GAIN -10.41 dB
REM REPLAYGAIN_TRACK_PEAK 1.046384
INDEX 01 69:05:33
TRACK 85 AUDIO
TITLE "Crimson II, Part 39"
REM REPLAYGAIN_TRACK_GAIN -10.37 dB
REM REPLAYGAIN_TRACK_PEAK 1.065853
INDEX 01 69:29:25
TRACK 86 AUDIO
TITLE "Crimson II, Part 40"
REM REPLAYGAIN_TRACK_GAIN -10.60 dB
REM REPLAYGAIN_TRACK_PEAK 1.059033
INDEX 01 69:53:35
TRACK 87 AUDIO
TITLE "Crimson II, Part 41"
REM REPLAYGAIN_TRACK_GAIN -10.07 dB
REM REPLAYGAIN_TRACK_PEAK 1.051512
INDEX 01 70:17:27
TRACK 88 AUDIO
TITLE "Crimson II, Part 42"
REM REPLAYGAIN_TRACK_GAIN -10.27 dB
REM REPLAYGAIN_TRACK_PEAK 1.047072
INDEX 01 70:39:08
TRACK 89 AUDIO
TITLE "Crimson II, Part 43"
REM REPLAYGAIN_TRACK_GAIN -10.34 dB
REM REPLAYGAIN_TRACK_PEAK 1.061442
INDEX 01 71:11:12
TRACK 90 AUDIO
TITLE "Crimson II, Part 44"
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
REM REPLAYGAIN_TRACK_PEAK 1.073117
INDEX 01 71:53:55
TRACK 91 AUDIO
TITLE "Crimson II, Part 45"
REM REPLAYGAIN_TRACK_GAIN -10.01 dB
REM REPLAYGAIN_TRACK_PEAK 1.053137
INDEX 01 72:36:17
TRACK 92 AUDIO
TITLE "Crimson II, Part 46"
REM REPLAYGAIN_TRACK_GAIN -10.58 dB
REM REPLAYGAIN_TRACK_PEAK 1.091899
INDEX 01 73:40:45
TRACK 93 AUDIO
TITLE "Crimson II, Part 47"
REM REPLAYGAIN_TRACK_GAIN -10.40 dB
REM REPLAYGAIN_TRACK_PEAK 1.069606
INDEX 01 74:10:05
TRACK 94 AUDIO
TITLE "Crimson II, Part 48"
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
REM REPLAYGAIN_TRACK_PEAK 1.041885
INDEX 01 75:12:34
TRACK 95 AUDIO
TITLE "Crimson II, Part 49"
REM REPLAYGAIN_TRACK_GAIN -10.19 dB
REM REPLAYGAIN_TRACK_PEAK 1.150353
INDEX 01 75:53:31
TRACK 96 AUDIO
TITLE "Crimson II, Part 50"
REM REPLAYGAIN_TRACK_GAIN -10.00 dB
REM REPLAYGAIN_TRACK_PEAK 1.036077
INDEX 01 77:07:17
TRACK 97 AUDIO
TITLE "Crimson II, Part 51"
REM REPLAYGAIN_TRACK_GAIN -10.74 dB
REM REPLAYGAIN_TRACK_PEAK 1.055573
INDEX 01 77:40:33
TRACK 98 AUDIO
TITLE "Crimson II, Part 52"
REM REPLAYGAIN_TRACK_GAIN -9.63 dB
REM REPLAYGAIN_TRACK_PEAK 1.034923
INDEX 01 78:58:27
TRACK 99 AUDIO
TITLE "Crimson II, Part 53"
REM REPLAYGAIN_TRACK_GAIN -9.48 dB
REM REPLAYGAIN_TRACK_PEAK 1.073862
INDEX 01 79:27:13

View File

@ -59,8 +59,15 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain)
// General properties
mf.SubTrack = md.SubTrack()
if mf.SubTrack >= 0 {
mf.Offset = md.Offset()
mf.Duration = md.SubTrackLength()
mf.CUEFile = md.first(model.TagCUEFile)
} else {
mf.Duration = md.Length()
}
mf.HasCoverArt = md.HasPicture()
mf.Duration = md.Length()
mf.BitRate = md.AudioProperties().BitRate
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
@ -94,8 +101,8 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
// MediaFile struct. This is to avoid redundancy in the DB
//
// Remove all tags from the main section that are not flagged as album tags
for tag, conf := range model.TagMainMappings() {
if !conf.Album {
for tag, config := range model.TagMainMappings() {
if !config.Album {
delete(mf.Tags, tag)
}
}

View File

@ -11,6 +11,8 @@ import (
"time"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -66,10 +68,20 @@ func NewPair(key, value string) string {
}
func New(filePath string, info Info) Metadata {
cueRawTags := model.RawTags{}
if conf.Server.Scanner.CUESheetSupport != consts.CUEDisable {
for key, tags := range info.Tags {
lowerKey := strings.ToLower(key)
if strings.HasPrefix(lowerKey, "cue_track") && len(tags) > 0 {
cueRawTags[lowerKey] = tags
}
}
}
return Metadata{
filePath: filePath,
fileInfo: info.FileInfo,
tags: clean(filePath, info.Tags),
cueTags: cueRawTags,
audioProps: info.AudioProperties,
hasPicture: info.HasPicture,
}
@ -79,10 +91,21 @@ type Metadata struct {
filePath string
fileInfo FileInfo
tags model.Tags
cueTags model.RawTags
audioProps AudioProperties
hasPicture bool
}
func (md Metadata) WithCUERawTags(rawTags model.RawTags) Metadata {
return Metadata{
filePath: md.filePath,
fileInfo: md.fileInfo,
tags: clean(md.filePath, rawTags),
audioProps: md.audioProps,
hasPicture: md.hasPicture,
}
}
func (md Metadata) FilePath() string { return md.filePath }
func (md Metadata) ModTime() time.Time { return md.fileInfo.ModTime() }
func (md Metadata) BirthTime() time.Time { return md.fileInfo.BirthTime() }
@ -90,8 +113,10 @@ func (md Metadata) Size() int64 { return md.fileInfo.Size() }
func (md Metadata) Suffix() string {
return strings.ToLower(strings.TrimPrefix(path.Ext(md.filePath), "."))
}
func (md Metadata) AudioProperties() AudioProperties { return md.audioProps }
func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 }
func (md Metadata) AudioProperties() AudioProperties { return md.audioProps }
func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 }
func (md Metadata) HasPicture() bool { return md.hasPicture }
func (md Metadata) All() model.Tags { return md.tags }
func (md Metadata) Strings(key model.TagName) []string { return md.tags[key] }
@ -105,6 +130,46 @@ func (md Metadata) Float(key model.TagName, def ...float64) float64 {
}
func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) }
func (md Metadata) SubTrack() int {
sub := md.first(model.TagCUESubTrack)
if len(sub) == 0 {
return -1
}
s, err := strconv.Atoi(sub)
if err != nil {
return -1
}
return s
}
func (md Metadata) Offset() float32 {
offsetStr := md.first(model.TagCUETrackOffset)
if offsetStr == "" {
return 0
}
offsetMs, err := strconv.ParseFloat(offsetStr, 32)
if err != nil {
return 0
}
return float32(offsetMs)
}
func (md Metadata) SubTrackLength() float32 {
lengthStr := md.first(model.TagCUETrackDuration)
if lengthStr == "" {
return 0
}
lengthMs, err := strconv.ParseFloat(lengthStr, 32)
if err != nil {
return 0
}
return float32(lengthMs)
}
func (md Metadata) CueTags() model.RawTags {
return md.cueTags
}
func (md Metadata) Gain(key model.TagName) *float64 {
v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1))
return nullableFloat(v)

View File

@ -5,12 +5,15 @@ import (
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Metadata", func() {
@ -130,6 +133,23 @@ var _ = Describe("Metadata", func() {
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
})
It("should store cue tags if enabled", func() {
props.Tags = model.RawTags{
"cue_track01_musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"},
}
md = metadata.New(filePath, props)
Expect(md.CueTags()).To(HaveKeyWithValue("cue_track01_musicbrainz_trackid", []string{"8f84da07-09a0-477b-b216-cc982dabcde1"}))
})
It("should not store cue tags if disabled", func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEDisable
props.Tags = model.RawTags{
"cue_track01_musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"},
}
md = metadata.New(filePath, props)
Expect(md.CueTags()).To(BeEmpty())
})
})
DescribeTable("Date",

View File

@ -254,4 +254,11 @@ const (
TagMusicBrainzConductorID TagName = "musicbrainz_conductorid"
TagMusicBrainzArrangerID TagName = "musicbrainz_arrangerid"
TagMusicBrainzPerformerID TagName = "musicbrainz_performerid"
// CUE sheet tags
TagCUESheet TagName = "cuesheet"
TagCUESubTrack TagName = "cuesubtrack"
TagCUETrackOffset TagName = "cuetrackoffset"
TagCUETrackDuration TagName = "cuetrackduration"
TagCUEFile TagName = "cuefile"
)

View File

@ -1,5 +1,9 @@
package model
const (
RawTranscodeCmd = "zero_copy_subtrack_transcode"
)
type Transcoding struct {
ID string `structs:"id" json:"id"`
Name string `structs:"name" json:"name"`

View File

@ -12,11 +12,12 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
type mediaFileRepository struct {
@ -130,7 +131,7 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
m.CreatedAt = time.Now()
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID, "sub_track": m.SubTrack}, m.ID, &dbMediaFile{MediaFile: m})
if err != nil {
return err
}

View File

@ -198,6 +198,17 @@ main:
type: pair
explicitstatus:
aliases: [ itunesadvisory, rtng ]
cuefile:
aliases: [ cuefile ]
cuesubtrack:
aliases: [ cuesubtrack ]
cuetrackoffset:
aliases: [ cuetrackoffset ]
cuetrackduration:
aliases: [ cuetrackduration ]
cuesheet:
aliases: [ cuesheet ]
maxLength: 10240
# Additional tags. You can add new tags without the need to modify the code. They will be available as fields
# for smart playlists

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata/cue"
"github.com/navidrome/navidrome/utils/chrono"
)
@ -21,14 +22,21 @@ func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash strin
job: job,
path: path,
audioFiles: make(map[string]fs.DirEntry),
cueFiles: make(map[string]fs.DirEntry),
imageFiles: make(map[string]fs.DirEntry),
albumIDMap: make(map[string]string),
updTime: updTime,
prevHash: hash,
cueSheets: make(map[string]*cueSheetInfo),
}
return f
}
type cueSheetInfo struct {
CUEFile string
CueSheet *cue.Cuesheet
}
type folderEntry struct {
job *scanJob
elapsed chrono.Meter
@ -37,6 +45,7 @@ type folderEntry struct {
modTime time.Time // From FS
updTime time.Time // from DB
audioFiles map[string]fs.DirEntry
cueFiles map[string]fs.DirEntry
imageFiles map[string]fs.DirEntry
numPlaylists int
numSubFolders int
@ -48,10 +57,11 @@ type folderEntry struct {
artists model.Artists
tags model.TagList
missingTracks []*model.MediaFile
cueSheets map[string]*cueSheetInfo // Mapping of media file name to CUE sheet info
}
func (f *folderEntry) hasNoFiles() bool {
return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0
return len(f.audioFiles) == 0 && len(f.cueFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0
}
func (f *folderEntry) isEmpty() bool {
@ -97,6 +107,8 @@ func (f *folderEntry) hash() string {
slices.Sort(audioKeys)
imageKeys := slices.Collect(maps.Keys(f.imageFiles))
slices.Sort(imageKeys)
cueKeys := slices.Collect(maps.Keys(f.cueFiles))
slices.Sort(cueKeys)
// Include audio files with their size and modtime
for _, key := range audioKeys {
@ -114,5 +126,13 @@ func (f *folderEntry) hash() string {
}
}
// Include CUE files with their size and modtime
for _, key := range cueKeys {
_, _ = io.WriteString(h, key)
if info, err := f.cueFiles[key].Info(); err == nil {
_, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String())
}
}
return hex.EncodeToString(h.Sum(nil))
}

View File

@ -4,11 +4,12 @@ import (
"io/fs"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("folder_entry", func() {
@ -111,6 +112,17 @@ var _ = Describe("folder_entry", func() {
entry.numSubFolders = 3
Expect(entry.hasNoFiles()).To(BeFalse())
})
It("returns false when folder has CUE files", func() {
entry.cueFiles["album.cue"] = &fakeDirEntry{name: "album.cue"}
Expect(entry.hasNoFiles()).To(BeFalse())
})
It("returns false when folder has both CUE and audio files", func() {
entry.cueFiles["album.cue"] = &fakeDirEntry{name: "album.cue"}
entry.audioFiles["album.flac"] = &fakeDirEntry{name: "album.flac"}
Expect(entry.hasNoFiles()).To(BeFalse())
})
})
Describe("isEmpty", func() {
@ -382,6 +394,79 @@ var _ = Describe("folder_entry", func() {
Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters
Expect(hash).To(MatchRegexp("^[a-f0-9]{32}$"))
})
It("produces different hash when CUE files are added", func() {
hash1 := entry.hash()
entry.cueFiles["album.cue"] = &fakeDirEntry{name: "album.cue"}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when CUE file size changes", func() {
entry.cueFiles["album.cue"] = &fakeDirEntry{
name: "album.cue",
fileInfo: &fakeFileInfo{
name: "album.cue",
size: 1000,
modTime: time.Now(),
},
}
hash1 := entry.hash()
entry.cueFiles["album.cue"] = &fakeDirEntry{
name: "album.cue",
fileInfo: &fakeFileInfo{
name: "album.cue",
size: 2000, // Different size
modTime: time.Now(),
},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when CUE file modification time changes", func() {
baseTime := time.Now()
entry.cueFiles["album.cue"] = &fakeDirEntry{
name: "album.cue",
fileInfo: &fakeFileInfo{
name: "album.cue",
size: 1000,
modTime: baseTime,
},
}
hash1 := entry.hash()
entry.cueFiles["album.cue"] = &fakeDirEntry{
name: "album.cue",
fileInfo: &fakeFileInfo{
name: "album.cue",
size: 1000,
modTime: baseTime.Add(1 * time.Hour), // Different modtime
},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces deterministic hash with CUE files in different order", func() {
entry.cueFiles["z.cue"] = &fakeDirEntry{name: "z.cue"}
entry.cueFiles["a.cue"] = &fakeDirEntry{name: "a.cue"}
hash1 := entry.hash()
// Recreate with different order
entry.cueFiles = map[string]fs.DirEntry{
"a.cue": &fakeDirEntry{name: "a.cue"},
"z.cue": &fakeDirEntry{name: "z.cue"},
}
hash2 := entry.hash()
Expect(hash1).To(Equal(hash2))
})
})
Describe("isOutdated", func() {
@ -492,6 +577,65 @@ var _ = Describe("folder_entry", func() {
entry.audioFiles["track3.mp3"] = &fakeDirEntry{name: "track3.mp3"}
Expect(entry.isOutdated()).To(BeTrue())
})
It("handles folder with CUE sheets correctly", func() {
// Create new folder entry
folderPath := "music/lossless/album"
folderID := model.FolderID(lib, folderPath)
entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "")
// Add CUE file and corresponding audio file
entry.cueFiles["album.cue"] = &fakeDirEntry{
name: "album.cue",
fileInfo: &fakeFileInfo{
name: "album.cue",
size: 2000,
modTime: time.Now(),
},
}
entry.audioFiles["album.flac"] = &fakeDirEntry{
name: "album.flac",
fileInfo: &fakeFileInfo{
name: "album.flac",
size: 500000000,
modTime: time.Now(),
},
}
entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"}
entry.modTime = time.Now()
entry.imagesUpdatedAt = time.Now()
// Folder should not be empty
Expect(entry.hasNoFiles()).To(BeFalse())
Expect(entry.isEmpty()).To(BeFalse())
// Set previous hash to current hash
entry.prevHash = entry.hash()
entry.updTime = time.Now()
// Should not be outdated
Expect(entry.isOutdated()).To(BeFalse())
// Modify CUE file and verify it becomes outdated
entry.cueFiles["album.cue"] = &fakeDirEntry{
name: "album.cue",
fileInfo: &fakeFileInfo{
name: "album.cue",
size: 2500, // Different size
modTime: time.Now().Add(1 * time.Hour),
},
}
Expect(entry.isOutdated()).To(BeTrue())
})
It("correctly initializes cueSheets map", func() {
folderPath := "music/test"
folderID := model.FolderID(lib, folderPath)
entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "")
Expect(entry.cueSheets).ToNot(BeNil())
Expect(entry.cueSheets).To(BeEmpty())
})
})
})

View File

@ -1,19 +1,23 @@
package scanner
import (
"bytes"
"cmp"
"context"
"errors"
"fmt"
"io/fs"
"maps"
"path"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Masterminds/squirrel"
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
@ -21,6 +25,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/model/metadata/cue"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/pl"
"github.com/navidrome/navidrome/utils/slice"
@ -216,39 +221,89 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
log.Error(p.ctx, "Scanner: Error loading mediafiles from DB", "folder", entry.path, err)
return entry, err
}
dbTracks := make(map[string]*model.MediaFile)
tracksFromDB := make(map[string][]*model.MediaFile)
for mf, err := range cursor {
if err != nil {
log.Error(p.ctx, "Scanner: Error loading mediafiles from DB", "folder", entry.path, err)
return entry, err
}
dbTracks[mf.Path] = &mf
tracksFromDB[mf.Path] = append(tracksFromDB[mf.Path], &mf)
}
// Get list of files to import, based on modtime (or all if fullScan),
// leave in dbTracks only tracks that are missing (not found in the FS)
filesToImport := make(map[string]*model.MediaFile, len(entry.audioFiles))
filesToImport := make(map[string][]*model.MediaFile, len(entry.audioFiles))
for afPath, af := range entry.audioFiles {
fullPath := path.Join(entry.path, afPath)
dbTrack, foundInDB := dbTracks[fullPath]
dbTracks, foundInDB := tracksFromDB[fullPath]
if !foundInDB || p.state.fullScan {
filesToImport[fullPath] = dbTrack
filesToImport[fullPath] = dbTracks
} else {
if len(dbTracks) == 0 {
filesToImport[fullPath] = nil
continue
}
dbTrack := dbTracks[0] // Take the first track if multiple (CUE sheets). All subtracks share the same modtime.
info, err := af.Info()
if err != nil {
log.Warn(p.ctx, "Scanner: Error getting file info", "folder", entry.path, "file", af.Name(), err)
p.state.sendWarning(fmt.Sprintf("Error getting file info for %s/%s: %v", entry.path, af.Name(), err))
return entry, nil
}
added := false
if info.ModTime().After(dbTrack.UpdatedAt) || dbTrack.Missing {
filesToImport[fullPath] = dbTrack
filesToImport[fullPath] = dbTracks
added = true
}
// Check associated CUE sheet files for updates if any exist
if !added && len(dbTracks) > 1 {
for cuePath, cueInfo := range entry.cueFiles {
if dbTrack.CUEFile != cuePath {
continue
}
cueFileInfo, err := cueInfo.Info()
if err != nil {
log.Warn(p.ctx, "Scanner: Error getting CUE file info", "folder", entry.path, "file", cueInfo.Name(), err)
p.state.sendWarning(fmt.Sprintf("Error getting CUE file info for %s/%s: %v", entry.path, cueInfo.Name(), err))
return entry, nil
}
if cueFileInfo.ModTime().After(dbTrack.UpdatedAt) {
filesToImport[fullPath] = dbTracks
}
}
}
}
delete(dbTracks, fullPath)
delete(tracksFromDB, fullPath)
}
// Remaining dbTracks are tracks that were not found in the FS, so they should be marked as missing
entry.missingTracks = slices.Collect(maps.Values(dbTracks))
// Process CUE sheets (if any)
for cfPath, af := range entry.cueFiles {
fullPath := path.Join(entry.path, cfPath)
cueSheet, err := cue.ReadFromFileFS(entry.job.fs.(fs.FS), fullPath)
if err != nil {
log.Warn(p.ctx, "Scanner: Error reading CUE sheet", "folder", entry.path, "file", af.Name(), err)
p.state.sendWarning(fmt.Sprintf("Error reading CUE sheet %s/%s: %v", entry.path, af.Name(), err))
continue
}
for afPath := range entry.audioFiles {
fullMediaPath := path.Join(entry.path, afPath)
for _, mediaFile := range cueSheet.File {
if mediaFile.FileName == afPath {
if _, found := filesToImport[fullMediaPath]; !found {
filesToImport[fullMediaPath] = nil
}
entry.cueSheets[fullMediaPath] = &cueSheetInfo{
CUEFile: path.Base(fullPath),
CueSheet: cueSheet}
}
}
}
}
// Remaining tracksFromDB are tracks that were not found in the FS, so they should be marked as missing
for _, tracks := range tracksFromDB {
entry.missingTracks = append(entry.missingTracks, tracks...)
}
// Load metadata from files that need to be imported
if len(filesToImport) > 0 {
@ -266,12 +321,89 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
return entry, nil
}
func (p *phaseFolders) extractTagsFromCUE(md metadata.Metadata, entry *folderEntry) []model.RawTags {
cueStr := md.String(model.TagCUESheet)
if conf.Server.Scanner.CUESheetSupport == consts.CUEDisable {
return nil
}
if cueStr == "" && strings.ToLower(conf.Server.Scanner.CUESheetSupport) == consts.CUEEmbedded {
return nil
}
extractor, err := cue.NewExtractor(&md)
if err != nil {
log.Error("Can't create CUE tags extractor", "filePath", md.FilePath(), err)
return nil
}
modes := strings.Split(conf.Server.Scanner.CUESheetSupport, ",")
for _, mode := range modes {
var tracks []model.RawTags
switch strings.TrimSpace(strings.ToLower(mode)) {
case consts.CUEEmbedded:
if cueStr == "" {
continue
}
cueSheet, err := cue.ReadCue(bytes.NewBuffer([]byte(cueStr)))
if err != nil {
log.Error("Can't read embedded CUE", "filePath", md.FilePath(), "cue", cueStr, err)
continue
}
tracks, err = extractor.Extract(cueSheet, "")
if err != nil {
log.Error("Can't extract tags from embedded CUE", "filePath", md.FilePath(), err)
continue
}
case consts.CUEExternal:
if cueSheetInfo, ok := entry.cueSheets[md.FilePath()]; ok {
tracks, err = extractor.Extract(cueSheetInfo.CueSheet, cueSheetInfo.CUEFile)
if err != nil {
log.Error("Can't extract tags from external CUE", "filePath", md.FilePath(), err)
continue
}
}
}
if len(tracks) > 0 {
return tracks
}
}
return nil
}
const filesBatchSize = 200
func (p *phaseFolders) trackProcess(md metadata.Metadata, entry *folderEntry, newMf, prevMf *model.MediaFile, uniqueTags map[string]model.Tag) {
for _, t := range newMf.Tags.FlattenAll() {
uniqueTags[t.ID] = t
}
// Keep track of any album ID changes, to reassign annotations later
prevAlbumID := ""
if prevMf != nil {
prevAlbumID = prevMf.AlbumID
} else {
prevAlbumID = md.AlbumID(*newMf, p.prevAlbumPIDConf)
}
_, ok := entry.albumIDMap[newMf.AlbumID]
if prevAlbumID != newMf.AlbumID && !ok {
entry.albumIDMap[newMf.AlbumID] = prevAlbumID
}
}
func getPrevMediaFile(toImport []*model.MediaFile, index int) *model.MediaFile {
if index < len(toImport) {
return toImport[index]
}
return nil
}
// loadTagsFromFiles reads metadata from the files in the given list and populates
// the entry's tracks and tags with the results.
func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string]*model.MediaFile) error {
tracks := make([]model.MediaFile, 0, len(toImport))
func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string][]*model.MediaFile) error {
uniqueTags := make(map[string]model.Tag, len(toImport))
for chunk := range slice.CollectChunks(maps.Keys(toImport), filesBatchSize) {
allInfo, err := entry.job.fs.ReadTags(chunk...)
@ -281,26 +413,22 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string
}
for filePath, info := range allInfo {
md := metadata.New(filePath, info)
track := md.ToMediaFile(entry.job.lib.ID, entry.id)
tracks = append(tracks, track)
for _, t := range track.Tags.FlattenAll() {
uniqueTags[t.ID] = t
cueRawTags := p.extractTagsFromCUE(md, entry)
if len(cueRawTags) > 0 {
for i, rawTags := range cueRawTags {
trackMd := md.WithCUERawTags(rawTags)
track := trackMd.ToMediaFile(entry.job.lib.ID, entry.id)
entry.tracks = append(entry.tracks, track)
p.trackProcess(trackMd, entry, &track, getPrevMediaFile(toImport[filePath], i), uniqueTags)
}
continue
}
// Keep track of any album ID changes, to reassign annotations later
prevAlbumID := ""
if prev := toImport[filePath]; prev != nil {
prevAlbumID = prev.AlbumID
} else {
prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf)
}
_, ok := entry.albumIDMap[track.AlbumID]
if prevAlbumID != track.AlbumID && !ok {
entry.albumIDMap[track.AlbumID] = prevAlbumID
}
track := md.ToMediaFile(entry.job.lib.ID, entry.id)
entry.tracks = append(entry.tracks, track)
p.trackProcess(md, entry, &track, getPrevMediaFile(toImport[filePath], 0), uniqueTags)
}
}
entry.tracks = tracks
entry.tags = slices.Collect(maps.Values(uniqueTags))
return nil
}

View File

@ -3,11 +3,16 @@ package scanner_test
import (
"context"
"errors"
"fmt"
"path/filepath"
"testing/fstest"
"time"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
@ -24,8 +29,6 @@ import (
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Easy aliases for the storagetest package
@ -53,7 +56,7 @@ var _ = Describe("Scanner", Ordered, func() {
tmpDir := GinkgoT().TempDir()
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL")
log.Warn("Using DB at " + conf.Server.DbPath)
//conf.Server.DbPath = ":memory:"
// conf.Server.DbPath = ":memory:"
db.Db().SetMaxOpenConns(1)
})
@ -916,6 +919,355 @@ var _ = Describe("Scanner", Ordered, func() {
Expect(albumArtistStats.SongCount).To(Equal(3)) // 3 songs
})
})
Describe("External CUE Sheet Support", func() {
var album func(...map[string]any) *fstest.MapFile
BeforeEach(func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEExternal
album = template(_t{
"albumArtist": "Test Artist",
"album": "Test Album",
"year": 2023,
"duration": 20 * time.Minute.Milliseconds(),
})
// Create a CUE file content as raw text
cueContent := []byte(`FILE "album.flac" FLAC
TRACK 01 AUDIO
TITLE "Track 1"
PERFORMER "Test Artist"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Track 2"
PERFORMER "Test Artist"
INDEX 01 03:30:00
TRACK 03 AUDIO
TITLE "Track 3"
PERFORMER "Test Artist"
INDEX 01 07:15:00`)
createFS(fstest.MapFS{
"music/Test Artist/Test Album/album.cue": {Data: cueContent},
"music/Test Artist/Test Album/album.flac": album(),
"music/Test Artist/Test Album/cover.jpg": {Data: []byte{0xFF, 0xD8, 0xFF}},
})
})
It("should detect CUE files during scan", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Verify folder was created
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
// Find folder by checking all folders
var foundFolder *model.Folder
for i := range folders {
if folders[i].Name == "Test Album" {
foundFolder = &folders[i]
break
}
}
Expect(foundFolder).ToNot(BeNil(), "Test Album folder should exist")
// Folder should have 1 audio file
Expect(foundFolder.NumAudioFiles).To(BeNumerically(">=", 1))
// Get all tracks
allTracks, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
// Should have 3 tracks from the CUE sheet
Expect(allTracks).To(HaveLen(3))
// Verify each track has CUEFile field set
for i, track := range allTracks {
Expect(track.Duration).To(BeNumerically(">=", float32(1)))
Expect(track.Title).To(Equal(fmt.Sprintf("Track %d", i+1)))
Expect(track.Artist).To(Equal(fmt.Sprintf("Test Artist")))
Expect(track.Album).To(Equal(fmt.Sprintf("Test Album")))
Expect(track.SubTrack).To(BeNumerically("==", i))
Expect(track.CUEFile).To(ContainSubstring("album.cue"))
}
})
It("should not scan CUE files when external support is disabled", func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEDisable
Expect(runScanner(ctx, true)).To(Succeed())
// Get all tracks
allTracks, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
// Should have at least 1 track (the FLAC file itself)
Expect(allTracks).To(HaveLen(1))
Expect(allTracks[0].CUEFile).To(BeEmpty())
})
It("should not scan CUE files when embedded support enabled", func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEEmbedded
Expect(runScanner(ctx, true)).To(Succeed())
// Get all tracks
allTracks, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
// Should have at least 1 track (the FLAC file itself)
Expect(allTracks).To(HaveLen(1))
Expect(allTracks[0].CUEFile).To(BeEmpty())
})
})
Describe("CUE Sheet Support", func() {
var image1 func(...map[string]any) *fstest.MapFile
var image2 func(...map[string]any) *fstest.MapFile
BeforeEach(func() {
image1 = template(_t{
"albumArtist": "Test Artist",
"album": "Test Album",
"year": 2023,
"duration": 10 * time.Minute.Milliseconds(),
"suffix": "flac",
"cuesheet": `REM GENRE Folk
REM DATE 2005
REM DISCID 9A0D690B
REM COMMENT ExactAudioCopy v0.99pb1
PERFORMER "Thanateros"
TITLE "Into The Otherworld"
FILE "disc1.flac" FLAC
TRACK 01 AUDIO
TITLE "Track 1"
PERFORMER "Test Artist"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Track 2"
PERFORMER "Test Artist"
INDEX 01 03:30:00
TRACK 03 AUDIO
TITLE "Track 3"
PERFORMER "Test Artist"
INDEX 01 07:15:00`,
})
image2 = template(_t{
"albumArtist": "Test Artist",
"album": "Test Album",
"year": 2023,
"duration": 5 * time.Minute.Milliseconds(),
"suffix": "flac",
"cuesheet": `REM GENRE Folk
REM DATE 2005
REM DISCID 9A0D690B
REM COMMENT ExactAudioCopy v0.99pb1
PERFORMER "Thanateros"
TITLE "Into The Otherworld"
FILE "disc2.flac" FLAC
TRACK 01 AUDIO
TITLE "Track 1"
PERFORMER "Test Artist"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Track 2"
PERFORMER "Test Artist"
INDEX 01 03:30:00`,
})
cueContent1 := []byte(`REM GENRE Folk
REM DATE 2005
REM DISCID 9A0D690B
REM COMMENT ExactAudioCopy v0.99pb1
PERFORMER "Thanateros External"
TITLE "Into The Otherworld External"
FILE "disc1.flac" FLAC
TRACK 01 AUDIO
TITLE "Crimson, Part 1"
PERFORMER "Test Artist"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Track 2 External"
PERFORMER "Test Artist"
INDEX 01 03:30:00
TRACK 03 AUDIO
TITLE "Track 3 External"
PERFORMER "Test Artist"
INDEX 01 07:15:00`)
cueContent2 := []byte(`REM GENRE Folk
REM DATE 2005
REM DISCID 9A0D690B
REM COMMENT ExactAudioCopy v0.99pb1
PERFORMER "Thanateros External"
TITLE "Into The Otherworld External"
FILE "disc2.flac" FLAC
TRACK 01 AUDIO
TITLE "Track 1 External"
PERFORMER "Test Artist"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Track 2 External"
PERFORMER "Test Artist"
INDEX 01 03:30:00`)
createFS(fstest.MapFS{
"music/Test Artist/Test Album/disc1.cue": {Data: cueContent1},
"music/Test Artist/Test Album/disc1.flac": image1(),
"music/Test Artist/Test Album/disc2.cue": {Data: cueContent2},
"music/Test Artist/Test Album/disc2.flac": image2(),
"music/Test Artist/Test Album/cover.jpg": {Data: []byte{0xFF, 0xD8, 0xFF}},
})
})
It("should handle folders with multiple audio files and CUE sheets (only external)", func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEExternal
Expect(runScanner(ctx, true)).To(Succeed())
// Verify folder exists and has audio files
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
var foundFolder *model.Folder
for i := range folders {
if folders[i].Name == "Test Album" {
foundFolder = &folders[i]
break
}
}
Expect(foundFolder).ToNot(BeNil(), "Test Album folder should exist")
Expect(foundFolder.NumAudioFiles).To(Equal(2))
allTracks, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(5))
allTracks, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Crimson, Part 1"},
})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(1))
Expect(allTracks[0].Tags).ToNot(HaveKey("cuesheet"), "Tags should not contain embedded cuesheet data")
Expect(allTracks[0].CUEFile).To(
Equal("disc1.cue"),
"CUEFile should reference the correct CUE sheet",
)
})
It("should handle folders with multiple audio files and CUE sheets (external,embedded)", func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEPreferExternal
Expect(runScanner(ctx, true)).To(Succeed())
// Verify folder exists and has audio files
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
var foundFolder *model.Folder
for i := range folders {
if folders[i].Name == "Test Album" {
foundFolder = &folders[i]
break
}
}
Expect(foundFolder).ToNot(BeNil(), "Test Album folder should exist")
Expect(foundFolder.NumAudioFiles).To(Equal(2))
allTracks, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(5))
allTracks, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Crimson, Part 1"},
})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(1))
Expect(allTracks[0].Tags).ToNot(HaveKey("cuesheet"), "Tags should not contain embedded cuesheet data")
Expect(allTracks[0].CUEFile).To(
Equal("disc1.cue"),
"CUEFile should reference the correct CUE sheet",
)
})
It("should handle folders with multiple audio files and CUE sheets (only embedded)", func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEEmbedded
Expect(runScanner(ctx, true)).To(Succeed())
// Verify folder exists and has audio files
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
var foundFolder *model.Folder
for i := range folders {
if folders[i].Name == "Test Album" {
foundFolder = &folders[i]
break
}
}
Expect(foundFolder).ToNot(BeNil(), "Test Album folder should exist")
Expect(foundFolder.NumAudioFiles).To(Equal(2))
allTracks, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(5))
allTracks, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Track 1", "artist": "Test Artist"},
})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(2))
for _, track := range allTracks {
Expect(track.Tags).ToNot(HaveKey("cuesheet"), "Tags should not contain embedded cuesheet data")
Expect(track.CUEFile).To(BeEmpty(), "CUEFile should be empty for embedded CUE support")
Expect(track.SubTrack).To(BeNumerically(">=", 0))
Expect(track.AlbumArtist).To(ContainSubstring("Test Artist"))
Expect(track.AlbumArtist).To(ContainSubstring("Thanateros"))
}
})
It("should handle folders with multiple audio files and CUE sheets (embedded,external)", func() {
conf.Server.Scanner.CUESheetSupport = consts.CUEPreferEmbedded
Expect(runScanner(ctx, true)).To(Succeed())
// Verify folder exists and has audio files
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
var foundFolder *model.Folder
for i := range folders {
if folders[i].Name == "Test Album" {
foundFolder = &folders[i]
break
}
}
Expect(foundFolder).ToNot(BeNil(), "Test Album folder should exist")
Expect(foundFolder.NumAudioFiles).To(Equal(2))
allTracks, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(5))
allTracks, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Track 1", "artist": "Test Artist"},
})
Expect(err).ToNot(HaveOccurred())
Expect(allTracks).To(HaveLen(2))
for _, track := range allTracks {
Expect(track.Tags).ToNot(HaveKey("cuesheet"), "Tags should not contain embedded cuesheet data")
Expect(track.CUEFile).To(BeEmpty(), "CUEFile should be empty for embedded CUE support")
Expect(track.SubTrack).To(BeNumerically(">=", 0))
Expect(track.AlbumArtist).To(ContainSubstring("Test Artist"))
Expect(track.AlbumArtist).To(ContainSubstring("Thanateros"))
}
})
})
})
func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*model.MediaFile, error) {

View File

@ -10,6 +10,7 @@ import (
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
@ -90,6 +91,10 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker
return nil
}
func isExternalCUEEnabled() bool {
return strings.Contains(strings.ToLower(conf.Server.Scanner.CUESheetSupport), consts.CUEExternal)
}
func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) {
// Check if directory exists before creating the folder entry
// This is important to avoid removing the folder from lastUpdates if it doesn't exist
@ -155,6 +160,8 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC
case model.IsImageFile(entry.Name()):
folder.imageFiles[entry.Name()] = entry
folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime)
case model.IsCueSheetFile(entry.Name()) && isExternalCUEEnabled():
folder.cueFiles[entry.Name()] = entry
}
}
}

View File

@ -8,13 +8,14 @@ import (
"path/filepath"
"testing/fstest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/sync/errgroup"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/sync/errgroup"
)
var _ = Describe("walk_dir_tree", func() {
@ -215,6 +216,182 @@ var _ = Describe("walk_dir_tree", func() {
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
})
})
Context("with CUE files", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = GinkgoT().Context()
})
It("should detect CUE files when external CUE support is enabled", func() {
conf.Server.Scanner.CUESheetSupport = "external"
fsys = &mockMusicFS{
FS: fstest.MapFS{
"root/album1/album.cue": {},
"root/album1/album.flac": {},
"root/album1/cover.jpg": {},
"root/album2/track1.mp3": {},
"root/album2/track2.mp3": {},
"root/album3/disc.cue": {},
"root/album3/disc1.flac": {},
"root/album3/disc2.flac": {},
"root/album3/booklet.pdf": {},
},
}
job = &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
results, err := walkDirTree(ctx, job)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
g := errgroup.Group{}
g.Go(func() error {
for folder := range results {
folders[folder.path] = folder
}
return nil
})
_ = g.Wait()
// Check album1 with CUE file
Expect(folders).To(HaveKey("root/album1"))
album1 := folders["root/album1"]
Expect(album1.cueFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("album.cue"),
))
Expect(album1.audioFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("album.flac"),
))
Expect(album1.imageFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("cover.jpg"),
))
// Check album2 without CUE files
Expect(folders).To(HaveKey("root/album2"))
album2 := folders["root/album2"]
Expect(album2.cueFiles).To(BeEmpty())
Expect(album2.audioFiles).To(HaveLen(2))
// Check album3 with CUE file and multiple audio files
Expect(folders).To(HaveKey("root/album3"))
album3 := folders["root/album3"]
Expect(album3.cueFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("disc.cue"),
))
Expect(album3.audioFiles).To(HaveLen(2))
})
It("should not detect CUE files when external CUE support is disabled", func() {
conf.Server.Scanner.CUESheetSupport = "none"
fsys = &mockMusicFS{
FS: fstest.MapFS{
"root/album/album.cue": {},
"root/album/album.flac": {},
"root/album/cover.jpg": {},
},
}
job = &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
results, err := walkDirTree(ctx, job)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
g := errgroup.Group{}
g.Go(func() error {
for folder := range results {
folders[folder.path] = folder
}
return nil
})
_ = g.Wait()
Expect(folders).To(HaveKey("root/album"))
album := folders["root/album"]
Expect(album.cueFiles).To(BeEmpty())
Expect(album.audioFiles).To(HaveLen(1))
Expect(album.imageFiles).To(HaveLen(1))
})
It("should handle folders with only CUE files", func() {
conf.Server.Scanner.CUESheetSupport = "external"
fsys = &mockMusicFS{
FS: fstest.MapFS{
"root/album/album.cue": {},
},
}
job = &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
results, err := walkDirTree(ctx, job)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
g := errgroup.Group{}
g.Go(func() error {
for folder := range results {
folders[folder.path] = folder
}
return nil
})
_ = g.Wait()
Expect(folders).To(HaveKey("root/album"))
album := folders["root/album"]
Expect(album.cueFiles).To(HaveLen(1))
Expect(album.audioFiles).To(BeEmpty())
Expect(album.hasNoFiles()).To(BeFalse()) // CUE files count as content
})
It("should handle multiple CUE files in one folder", func() {
conf.Server.Scanner.CUESheetSupport = "external"
fsys = &mockMusicFS{
FS: fstest.MapFS{
"root/album/disc1.cue": {},
"root/album/disc1.flac": {},
"root/album/disc2.cue": {},
"root/album/disc2.flac": {},
},
}
job = &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
results, err := walkDirTree(ctx, job)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
g := errgroup.Group{}
g.Go(func() error {
for folder := range results {
folders[folder.path] = folder
}
return nil
})
_ = g.Wait()
Expect(folders).To(HaveKey("root/album"))
album := folders["root/album"]
Expect(album.cueFiles).To(SatisfyAll(
HaveLen(2),
HaveKey("disc1.cue"),
HaveKey("disc2.cue"),
))
Expect(album.audioFiles).To(HaveLen(2))
})
})
})
Describe("helper functions", func() {

View File

@ -6,6 +6,8 @@ import (
"strings"
"sync"
"sync/atomic"
"github.com/navidrome/navidrome/model"
)
func NewMockFFmpeg(data string) *MockFFmpeg {
@ -23,7 +25,7 @@ func (ff *MockFFmpeg) IsAvailable() bool {
return true
}
func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) {
func (ff *MockFFmpeg) Transcode(context.Context, string, string, *model.MediaFile, int, int) (io.ReadCloser, error) {
if ff.Error != nil {
return nil, ff.Error
}