diff --git a/conf/configuration.go b/conf/configuration.go index 07e04e965..0adbcea30 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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) diff --git a/consts/consts.go b/consts/consts.go index fbb2c9429..4a27ef503 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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" diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index d134077ce..d6403902f 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -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 } diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index debe0b51e..fa908125f 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -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() diff --git a/core/ffmpeg/iso_reader.go b/core/ffmpeg/iso_reader.go new file mode 100644 index 000000000..61633f11e --- /dev/null +++ b/core/ffmpeg/iso_reader.go @@ -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) +} diff --git a/core/media_streamer.go b/core/media_streamer.go index c741ed476..9a2e63025 100644 --- a/core/media_streamer.go +++ b/core/media_streamer.go @@ -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 diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go index f5175495b..c68ace7b7 100644 --- a/core/media_streamer_test.go +++ b/core/media_streamer_test.go @@ -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()) diff --git a/db/migrations/20251231024638_support_media_with_subtracks.go b/db/migrations/20251231024638_support_media_with_subtracks.go new file mode 100644 index 000000000..e337a31a1 --- /dev/null +++ b/db/migrations/20251231024638_support_media_with_subtracks.go @@ -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 +} diff --git a/go.mod b/go.mod index 3f49496bc..74a575b03 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 71d6b9706..f9c125343 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/model/file_types.go b/model/file_types.go index 31590dd78..1d9b478a0 100644 --- a/model/file_types.go +++ b/model/file_types.go @@ -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" +} diff --git a/model/file_types_test.go b/model/file_types_test.go index 93301e151..3687b850f 100644 --- a/model/file_types_test.go +++ b/model/file_types_test.go @@ -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()) + }) + }) }) diff --git a/model/mediafile.go b/model/mediafile.go index 0ef26d746..a1cfe8116 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -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) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 635a61d30..b77e9e1ac 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -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() { diff --git a/model/metadata/cue/cuesheet.go b/model/metadata/cue/cuesheet.go new file mode 100644 index 000000000..677f4e913 --- /dev/null +++ b/model/metadata/cue/cuesheet.go @@ -0,0 +1,782 @@ +package cue + +import ( + "bufio" + "errors" + "fmt" + "io" + "io/fs" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/log" +) + +const ( + delims = "\t\n\r " + eol = "\n" + 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") + 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(`^0\d{12}$`) + 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 readCUEFields(cuesheet *Cuesheet, line string) error { + command := strings.ToUpper(readString(&line)) + var err error + switch command { + case "REM": + if cuesheet.Rem == nil { + cuesheet.Rem = RemData{} + } + cuesheet.Rem[readString(&line)] = line + case "CATALOG": + if len(cuesheet.Catalog) > 0 { + return ErrorDuplicateCatalog + } + if !catalogRegex.MatchString(line) { + return ErrorInvalidCatalog + } + cuesheet.Catalog = line + case "CDTEXTFILE": + err = setTagOrError(&cuesheet.CdTextFile, readString(&line), ErrorDuplicateCdTextFile, false) + case "TITLE": + err = setTagOrError(&cuesheet.Title, readString(&line), ErrorDuplicateTitle, true) + if errors.Is(err, ErrorDuplicateTitle) { + log.Error(fmt.Sprintf("already has title '%s' / '%s'", cuesheet.Title, line)) + } + case "PERFORMER": + err = setTagOrError(&cuesheet.Performer, readString(&line), ErrorDuplicatePerformer, true) + case "SONGWRITER": + err = setTagOrError(&cuesheet.SongWriter, readString(&line), ErrorDuplicateSongwriter, true) + case "PREGAP": + cuesheet.Pregap, err = frameFromString(readString(&line)) + if err != nil { + return err + } + case "POSTGAP": + cuesheet.Postgap, err = frameFromString(readString(&line)) + if err != nil { + return err + } + case "FILE": + cuesheet.File = append(cuesheet.File, + File{FileName: readString(&line), FileType: readString(&line)}) + return nil + default: + } + + return err +} + +func readFileFields(file *File, line string) (bool, error) { + command := strings.ToUpper(readString(&line)) + switch command { + case "TRACK": + track := Track{} + track.TrackNumber = readUint(&line) + if len(file.Tracks) != int(track.TrackNumber)-1 { + return false, ErrorTrackOutOfOrder + } + track.TrackDataType = readString(&line) + file.Tracks = append(file.Tracks, track) + return true, nil + default: + } + return false, nil +} + +func readTrackFields(track *Track, line string) (err error) { + command := strings.ToUpper(readString(&line)) + switch command { + case "FLAGS": + if track.Flags != None { + return ErrorDuplicateTrackFlags + } + track.Flags = None + for len(line) > 0 { + switch readString(&line) { + case "DCP": + track.Flags |= Dcp + case "4CH": + track.Flags |= FourCh + case "PRE": + track.Flags |= Pre + case "SCMS": + track.Flags |= Scms + default: + } + } + case "ISRC": + if len(track.ISRC) > 0 { + err = ErrorDuplicateTrackISRC + break + } + if !isrcRegex.MatchString(line) { + err = ErrorInvalidISRC + break + } + track.ISRC = line + case "TITLE": + err = setTagOrError(&track.Title, unquote(line), ErrorDuplicateTrackTitle, true) + case "PERFORMER": + err = setTagOrError(&track.Performer, unquote(line), ErrorDuplicateTrackPerformer, true) + case "SONGWRITER": + err = setTagOrError(&track.SongWriter, unquote(line), ErrorDuplicateTrackSongwriter, true) + case "PREGAP": + if track.PreGap > 0 { + err = ErrorDuplicateTrackPreGap + break + } + track.PreGap, err = frameFromString(readString(&line)) + case "POSTGAP": + if track.PostGap > 0 { + err = ErrorDuplicateTrackPostGap + break + } + track.PostGap, err = frameFromString(readString(&line)) + if err != nil { + break + } + case "INDEX": + index := TrackIndex{} + index.Number = readUint(&line) + index.Frame, err = frameFromString(readString(&line)) + if err != nil { + break + } + 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) + case "REM": + if track.Rem == nil { + track.Rem = RemData{} + } + track.Rem[readString(&line)] = line + default: + } + + return err +} + +// 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 +} + +// WriteCue writes CUESHEET to writer +// +//gocyclo:ignore +func WriteCue(w io.Writer, cuesheet *Cuesheet) error { + ws := bufio.NewWriter(w) + for k := range cuesheet.Rem { + _, err := ws.WriteString("REM " + k + " " + cuesheet.Rem[k] + eol) + if err != nil { + return err + } + } + + if len(cuesheet.Catalog) > 0 { + _, err := ws.WriteString("CATALOG " + cuesheet.Catalog + eol) + if err != nil { + return err + } + } + + if len(cuesheet.CdTextFile) > 0 { + _, err := ws.WriteString("CDTEXTFILE " + formatString(cuesheet.CdTextFile) + eol) + if err != nil { + return err + } + } + + if len(cuesheet.Title) > 0 { + _, err := ws.WriteString("TITLE " + formatString(cuesheet.Title) + eol) + if err != nil { + return err + } + } + + if len(cuesheet.Performer) > 0 { + _, err := ws.WriteString("PERFORMER " + formatString(cuesheet.Performer) + eol) + if err != nil { + return err + } + } + + if len(cuesheet.SongWriter) > 0 { + _, err := ws.WriteString("SONGWRITER " + formatString(cuesheet.SongWriter) + eol) + if err != nil { + return err + } + } + + if cuesheet.Pregap > 0 { + _, err := ws.WriteString("PREGAP " + cuesheet.Pregap.String() + eol) + if err != nil { + return err + } + } + + if cuesheet.Postgap > 0 { + _, err := ws.WriteString("POSTGAP " + cuesheet.Postgap.String() + eol) + if err != nil { + return err + } + } + + for i := 0; i < len(cuesheet.File); i++ { + file := &cuesheet.File[i] + _, err := ws.WriteString("FILE " + formatString(file.FileName) + + " " + file.FileType + eol) + if err != nil { + return err + } + + for i := 0; i < len(file.Tracks); i++ { + track := &file.Tracks[i] + + _, err := ws.WriteString(" TRACK " + formatTrackNumber(track.TrackNumber) + + " " + track.TrackDataType + eol) + if err != nil { + return err + } + + if track.Flags != None { + _, err := ws.WriteString(" FLAGS") + if err != nil { + return err + } + if (track.Flags & Dcp) != 0 { + _, err := ws.WriteString(" DCP") + if err != nil { + return err + } + } + if (track.Flags & FourCh) != 0 { + _, err := ws.WriteString(" 4CH") + if err != nil { + return err + } + } + if (track.Flags & Pre) != 0 { + _, err := ws.WriteString(" PRE") + if err != nil { + return err + } + } + if (track.Flags & Scms) != 0 { + _, err := ws.WriteString(" SCMS") + if err != nil { + return err + } + } + if _, err := ws.WriteString(eol); err != nil { + return err + } + } + + if len(track.ISRC) > 0 { + _, err := ws.WriteString(" ISRC " + track.ISRC + eol) + if err != nil { + return err + } + } + + if len(track.Title) > 0 { + _, err := ws.WriteString(" TITLE " + formatString(track.Title) + eol) + if err != nil { + return err + } + } + + if len(track.Performer) > 0 { + _, err := ws.WriteString(" PERFORMER " + formatString(track.Performer) + eol) + if err != nil { + return err + } + } + + if len(track.SongWriter) > 0 { + _, err := ws.WriteString(" SONGWRITER " + formatString(track.SongWriter) + eol) + if err != nil { + return err + } + } + + if track.PreGap > 0 { + _, err := ws.WriteString(" PREGAP " + track.PreGap.String() + eol) + if err != nil { + return err + } + } + + if track.PostGap > 0 { + _, err := ws.WriteString(" POSTGAP " + track.PostGap.String() + eol) + if err != nil { + return err + } + } + + if track.Rem != nil { + for k := range track.Rem { + _, err := ws.WriteString(" REM " + k + " " + track.Rem[k] + eol) + if err != nil { + return err + } + } + } + + for i := 0; i < len(track.Index); i++ { + index := &track.Index[i] + _, err := ws.WriteString(" INDEX " + formatTrackNumber(index.Number) + + " " + index.Frame.String() + eol) + if err != nil { + return err + } + } + } + } + + return ws.Flush() +} + +func readString(s *string) string { + *s = strings.TrimLeft(*s, delims) + + if len(*s) > 0 && isQuoted(*s) { + v := unquote(*s) + *s = (*s)[len(v)+2:] + return v + } + for i := 0; i < len(*s); i++ { + if (*s)[i] == ' ' { + v := (*s)[0:i] + *s = (*s)[i+1:] + return v + } + } + v := *s + *s = "" + return v +} + +func readUint(s *string) uint { + v := readString(s) + if n, err := strconv.ParseUint(v, 10, 32); err == nil { + return uint(n) + } + return 0 +} + +func formatString(s string) string { + return quote(s, '"') +} + +func formatTrackNumber(n uint) string { + return leftPad(strconv.FormatUint(uint64(n), 10), "0", 2) +} + +func isQuoted(s string) bool { + if len(s) == 0 { + return false + } + return s[0] == '"' || s[0] == '\'' +} + +func quote(s string, quote byte) string { + buf := make([]byte, 0, 3*len(s)/2) + buf = append(buf, quote) + for i := 0; i < len(s); i++ { + c := s[i] + if c == quote || c == '\\' { + buf = append(buf, '\\') + buf = append(buf, c) + } else { + buf = append(buf, c) + } + } + buf = append(buf, quote) + return string(buf) +} + +func unquote(s string) string { + quote := s[0] + i := 1 + for ; i < len(s); i++ { + if s[i] == quote { + break + } + if s[i] == '\\' { + i++ + } + } + return s[1:i] +} + +func leftPad(s, padStr string, overallLen int) string { + padCountInt := 1 + ((overallLen - len(padStr)) / len(padStr)) + var retStr = strings.Repeat(padStr, padCountInt) + s + return retStr[(len(retStr) - overallLen):] +} + +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 "" +} diff --git a/model/metadata/cue/cuesheet_test.go b/model/metadata/cue/cuesheet_test.go new file mode 100644 index 000000000..32879f11b --- /dev/null +++ b/model/metadata/cue/cuesheet_test.go @@ -0,0 +1,273 @@ +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, + }, + } + + 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) + } + }) + } +} diff --git a/model/metadata/cue/tags_extractor.go b/model/metadata/cue/tags_extractor.go new file mode 100644 index 000000000..83fe4b44b --- /dev/null +++ b/model/metadata/cue/tags_extractor.go @@ -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 + } + } +} diff --git a/model/metadata/cue/testdata/invalid.binary b/model/metadata/cue/testdata/invalid.binary new file mode 100644 index 000000000..be6728129 Binary files /dev/null and b/model/metadata/cue/testdata/invalid.binary differ diff --git a/model/metadata/cue/testdata/parser-case-insensitivity.cue b/model/metadata/cue/testdata/parser-case-insensitivity.cue new file mode 100644 index 000000000..2d4534393 --- /dev/null +++ b/model/metadata/cue/testdata/parser-case-insensitivity.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-comments.cue b/model/metadata/cue/testdata/parser-comments.cue new file mode 100644 index 000000000..d7bd382e0 --- /dev/null +++ b/model/metadata/cue/testdata/parser-comments.cue @@ -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! diff --git a/model/metadata/cue/testdata/parser-duplicate-catalog.cue b/model/metadata/cue/testdata/parser-duplicate-catalog.cue new file mode 100644 index 000000000..adf8440d7 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-catalog.cue @@ -0,0 +1,2 @@ +CATALOG 0112223334445 +CATALOG 0112223334442 diff --git a/model/metadata/cue/testdata/parser-duplicate-cdtextfile.cue b/model/metadata/cue/testdata/parser-duplicate-cdtextfile.cue new file mode 100644 index 000000000..f579111d2 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-cdtextfile.cue @@ -0,0 +1,2 @@ +CDTEXTFILE my-file.txt +CDTEXTFILE my-file.txt diff --git a/model/metadata/cue/testdata/parser-duplicate-performer.cue b/model/metadata/cue/testdata/parser-duplicate-performer.cue new file mode 100644 index 000000000..492fdcabb --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-performer.cue @@ -0,0 +1,2 @@ +PERFORMER "Captain Beefheart" +PERFORMER "Captain Beefheart" diff --git a/model/metadata/cue/testdata/parser-duplicate-songwriter.cue b/model/metadata/cue/testdata/parser-duplicate-songwriter.cue new file mode 100644 index 000000000..ed4534eac --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-songwriter.cue @@ -0,0 +1,2 @@ +SONGWRITER Foobar +SONGWRITER Foobar diff --git a/model/metadata/cue/testdata/parser-duplicate-title.cue b/model/metadata/cue/testdata/parser-duplicate-title.cue new file mode 100644 index 000000000..ece5c5414 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-title.cue @@ -0,0 +1,2 @@ +TITLE "My title" +TITLE "My title" diff --git a/model/metadata/cue/testdata/parser-duplicate-track-flags.cue b/model/metadata/cue/testdata/parser-duplicate-track-flags.cue new file mode 100644 index 000000000..7d70b0c68 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-track-flags.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-duplicate-track-isrc.cue b/model/metadata/cue/testdata/parser-duplicate-track-isrc.cue new file mode 100644 index 000000000..cb7673589 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-track-isrc.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-duplicate-track-performer.cue b/model/metadata/cue/testdata/parser-duplicate-track-performer.cue new file mode 100644 index 000000000..f79f22161 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-track-performer.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-duplicate-track-postgap.cue b/model/metadata/cue/testdata/parser-duplicate-track-postgap.cue new file mode 100644 index 000000000..7a51e7839 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-track-postgap.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-duplicate-track-pregap.cue b/model/metadata/cue/testdata/parser-duplicate-track-pregap.cue new file mode 100644 index 000000000..eda6c6054 --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-track-pregap.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-duplicate-track-songwriter.cue b/model/metadata/cue/testdata/parser-duplicate-track-songwriter.cue new file mode 100644 index 000000000..e11295acf --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-track-songwriter.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-duplicate-track-title.cue b/model/metadata/cue/testdata/parser-duplicate-track-title.cue new file mode 100644 index 000000000..c288c984e --- /dev/null +++ b/model/metadata/cue/testdata/parser-duplicate-track-title.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-index-out-of-order.cue b/model/metadata/cue/testdata/parser-index-out-of-order.cue new file mode 100644 index 000000000..b9d3a8bb6 --- /dev/null +++ b/model/metadata/cue/testdata/parser-index-out-of-order.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-invalid-catalog.cue b/model/metadata/cue/testdata/parser-invalid-catalog.cue new file mode 100644 index 000000000..3bc366bfe --- /dev/null +++ b/model/metadata/cue/testdata/parser-invalid-catalog.cue @@ -0,0 +1 @@ +CATALOG 123 diff --git a/model/metadata/cue/testdata/parser-invalid-frames.cue b/model/metadata/cue/testdata/parser-invalid-frames.cue new file mode 100644 index 000000000..99cc236bb --- /dev/null +++ b/model/metadata/cue/testdata/parser-invalid-frames.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-invalid-isrc.cue b/model/metadata/cue/testdata/parser-invalid-isrc.cue new file mode 100644 index 000000000..60eee381f --- /dev/null +++ b/model/metadata/cue/testdata/parser-invalid-isrc.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-invalid-seconds.cue b/model/metadata/cue/testdata/parser-invalid-seconds.cue new file mode 100644 index 000000000..9b7107ec7 --- /dev/null +++ b/model/metadata/cue/testdata/parser-invalid-seconds.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-invalid-text.cue b/model/metadata/cue/testdata/parser-invalid-text.cue new file mode 100644 index 000000000..00b0409f3 --- /dev/null +++ b/model/metadata/cue/testdata/parser-invalid-text.cue @@ -0,0 +1 @@ +PERFORMER "" diff --git a/model/metadata/cue/testdata/parser-missing-file.cue b/model/metadata/cue/testdata/parser-missing-file.cue new file mode 100644 index 000000000..7defcac35 --- /dev/null +++ b/model/metadata/cue/testdata/parser-missing-file.cue @@ -0,0 +1,3 @@ +PERFORMER "Faithless" +REM GENRE Electronica +TITLE "Live in Berlin" diff --git a/model/metadata/cue/testdata/parser-missing-index.cue b/model/metadata/cue/testdata/parser-missing-index.cue new file mode 100644 index 000000000..1c88f6c9c --- /dev/null +++ b/model/metadata/cue/testdata/parser-missing-index.cue @@ -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 diff --git a/model/metadata/cue/testdata/parser-missing-track.cue b/model/metadata/cue/testdata/parser-missing-track.cue new file mode 100644 index 000000000..73e8f6698 --- /dev/null +++ b/model/metadata/cue/testdata/parser-missing-track.cue @@ -0,0 +1,4 @@ +PERFORMER "Faithless" +REM GENRE Electronica +TITLE "Live in Berlin" +FILE "Faithless - Live in Berlin.mp3" MP3 diff --git a/model/metadata/cue/testdata/parser-track-out-of-order-2.cue b/model/metadata/cue/testdata/parser-track-out-of-order-2.cue new file mode 100644 index 000000000..f325888f0 --- /dev/null +++ b/model/metadata/cue/testdata/parser-track-out-of-order-2.cue @@ -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" diff --git a/model/metadata/cue/testdata/parser-track-out-of-order.cue b/model/metadata/cue/testdata/parser-track-out-of-order.cue new file mode 100644 index 000000000..67ee3f210 --- /dev/null +++ b/model/metadata/cue/testdata/parser-track-out-of-order.cue @@ -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" diff --git a/model/metadata/cue/testdata/parser-wrong-track-indent.cue b/model/metadata/cue/testdata/parser-wrong-track-indent.cue new file mode 100644 index 000000000..ba5b5582c --- /dev/null +++ b/model/metadata/cue/testdata/parser-wrong-track-indent.cue @@ -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! diff --git a/model/metadata/cue/testdata/test.cue b/model/metadata/cue/testdata/test.cue new file mode 100644 index 000000000..e6b845b16 --- /dev/null +++ b/model/metadata/cue/testdata/test.cue @@ -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 \ No newline at end of file diff --git a/model/metadata/cue/testdata/test_1.cue b/model/metadata/cue/testdata/test_1.cue new file mode 100644 index 000000000..44cc04eaa --- /dev/null +++ b/model/metadata/cue/testdata/test_1.cue @@ -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 diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index c64e8c724..7361ab9d5 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -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) } } diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 1372d0034..8657cbb3d 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -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) diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 82afd8657..7c84fdb21 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -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", diff --git a/model/tag.go b/model/tag.go index 674f688ca..eb23ca139 100644 --- a/model/tag.go +++ b/model/tag.go @@ -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" ) diff --git a/model/transcoding.go b/model/transcoding.go index 9b81a7c9c..c525252a1 100644 --- a/model/transcoding.go +++ b/model/transcoding.go @@ -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"` diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 4749bb0be..66fa0d83f 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -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 } diff --git a/resources/mappings.yaml b/resources/mappings.yaml index d1da5c620..63d6f2bf9 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -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 diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index 9d8d0c571..2982fe48c 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -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)) } diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index 0328c6653..e24f64949 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -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()) + }) }) }) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index b493a94d4..90dac5119 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -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 } diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 351255ae8..6efd7dbd4 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -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) { diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index e6a694f2b..ccfde8460 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -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 } } } diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index c9add0bd1..55491033b 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -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() { diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index a792ae9d3..f20adff4c 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -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 }