mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge dbafe32135129eeb616c121512c9ba4db7f02354 into fd4a04339e43bf806d428c47ad6431c6f53402f9
This commit is contained in:
commit
4db48beb4e
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -7,10 +7,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
)
|
||||
|
||||
func TestFFmpeg(t *testing.T) {
|
||||
@ -26,26 +28,124 @@ var _ = Describe("ffmpeg", func() {
|
||||
ffmpegPath = "ffmpeg"
|
||||
ffmpegErr = nil
|
||||
})
|
||||
Describe("createFFmpegCommand", func() {
|
||||
Describe("createFFmpegCommandForMedia", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
mf := model.MediaFile{
|
||||
Path: "/music library/file.mp3",
|
||||
SubTrack: -1,
|
||||
}
|
||||
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "mp3", mf.Path, "", &mf, 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-f", "mp3", "-"}))
|
||||
})
|
||||
It("handles extra spaces in the command string", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
mf := model.MediaFile{
|
||||
Path: "/music library/file.mp3",
|
||||
SubTrack: -1,
|
||||
}
|
||||
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "mp3", mf.Path, "", &mf, 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-f", "mp3", "-"}))
|
||||
})
|
||||
Context("when command has time offset param", func() {
|
||||
It("creates a valid command line with offset", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
|
||||
mf := model.MediaFile{
|
||||
Path: "/music library/file.mp3",
|
||||
SubTrack: -1,
|
||||
}
|
||||
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -ss %t -", "mp3", mf.Path, "", &mf, 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "-f", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
Context("when command does not have time offset param", func() {
|
||||
It("adds time offset after the input file name", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
|
||||
mf := model.MediaFile{
|
||||
Path: "/music library/file.mp3",
|
||||
SubTrack: -1,
|
||||
}
|
||||
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "mp3", mf.Path, "", &mf, 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "-f", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
Context("for subtracks", func() {
|
||||
It("adds time only duration before -i", func() {
|
||||
mf := model.MediaFile{
|
||||
Path: "/music library/file.ape",
|
||||
SubTrack: 1,
|
||||
Offset: 0,
|
||||
Duration: 5.0,
|
||||
Title: "title",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
Year: 2019,
|
||||
TrackNumber: 5,
|
||||
Comment: "c",
|
||||
Genre: "rock",
|
||||
}
|
||||
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -f mp3 -", "ogg", mf.Path, "", &mf, 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-t", "00:00:05.000", "-i", "/music library/file.ape", "-b:a", "123k", "-f", "mp3",
|
||||
"-metadata", "title=title",
|
||||
"-metadata", "artist=Artist",
|
||||
"-metadata", "album=Album",
|
||||
"-metadata", "year=2019",
|
||||
"-metadata", "track=5",
|
||||
"-metadata", "comment=c",
|
||||
"-metadata", "genre=rock",
|
||||
"-metadata", "cuesheet=", "-"}))
|
||||
})
|
||||
})
|
||||
Context("for subtracks", func() {
|
||||
It("use source path instead of mediafile path", func() {
|
||||
mf := model.MediaFile{
|
||||
Path: "/music library/file.wv",
|
||||
Suffix: "wv",
|
||||
SubTrack: 1,
|
||||
Offset: 0,
|
||||
Duration: 5.0,
|
||||
Title: "title",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
Year: 2019,
|
||||
TrackNumber: 5,
|
||||
Comment: "c",
|
||||
Genre: "rock",
|
||||
}
|
||||
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "flac", "-", "", &mf, 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-t", "00:00:05.000", "-i", "-", "-b:a", "123k", "-f", "flac",
|
||||
"-metadata", "title=title",
|
||||
"-metadata", "artist=Artist",
|
||||
"-metadata", "album=Album",
|
||||
"-metadata", "year=2019",
|
||||
"-metadata", "track=5",
|
||||
"-metadata", "comment=c",
|
||||
"-metadata", "genre=rock",
|
||||
"-metadata", "cuesheet=", "-"}))
|
||||
})
|
||||
})
|
||||
Context("for subtracks", func() {
|
||||
It("flac to flac use intermediate path and no copy stream", func() {
|
||||
mf := model.MediaFile{
|
||||
Path: "/music library/file.flac",
|
||||
Suffix: "flac",
|
||||
SubTrack: 1,
|
||||
Offset: 0,
|
||||
Duration: 5.0,
|
||||
Title: "title",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
Year: 2019,
|
||||
TrackNumber: 5,
|
||||
Comment: "c",
|
||||
Genre: "rock",
|
||||
}
|
||||
args := createFFmpegCommandForMedia("ffmpeg -i %s -b:a %bk -", "flac", mf.Path, "intermediate", &mf, 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-t", "00:00:05.000", "-i", "/music library/file.flac", "-b:a", "123k", "-f", "flac",
|
||||
"-metadata", "title=title",
|
||||
"-metadata", "artist=Artist",
|
||||
"-metadata", "album=Album",
|
||||
"-metadata", "year=2019",
|
||||
"-metadata", "track=5",
|
||||
"-metadata", "comment=c",
|
||||
"-metadata", "genre=rock",
|
||||
"-metadata", "cuesheet=", "intermediate"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -90,10 +190,15 @@ var _ = Describe("ffmpeg", func() {
|
||||
// Use a command that generates audio indefinitely
|
||||
// -f lavfi uses FFmpeg's built-in audio source
|
||||
// -t 0 means no time limit (runs forever)
|
||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -"
|
||||
|
||||
mf := model.MediaFile{
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
SubTrack: -1,
|
||||
}
|
||||
|
||||
// The input file is not used here, but we need to provide a valid path to the Transcode function
|
||||
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
|
||||
stream, err := ff.Transcode(ctx, command, "opus", &mf, 128, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
@ -114,8 +219,13 @@ var _ = Describe("ffmpeg", func() {
|
||||
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
mf := model.MediaFile{
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
SubTrack: -1,
|
||||
}
|
||||
|
||||
// This should fail immediately
|
||||
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
|
||||
_, err := ff.Transcode(ctx, "ffmpeg -i %s -", "opus", &mf, 128, 0)
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
})
|
||||
@ -141,8 +251,13 @@ var _ = Describe("ffmpeg", func() {
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mf := model.MediaFile{
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
SubTrack: -1,
|
||||
}
|
||||
|
||||
// Start a process that will run for a while
|
||||
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
|
||||
stream, err := ff.Transcode(ctx, longRunningCmd, "opus", &mf, 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
|
||||
127
core/ffmpeg/iso_reader.go
Normal file
127
core/ffmpeg/iso_reader.go
Normal file
@ -0,0 +1,127 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kdomanski/iso9660"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
var ErrorNotAnISO = errors.New("not iso file")
|
||||
|
||||
type isoWVReader struct {
|
||||
f *os.File
|
||||
i *iso9660.Image
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (r *isoWVReader) Read(p []byte) (int, error) {
|
||||
if r.r != nil {
|
||||
return r.r.Read(p)
|
||||
}
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
func (r *isoWVReader) Close() error {
|
||||
if r.f != nil {
|
||||
return r.f.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isISO(f *os.File) bool {
|
||||
_, err := f.Seek(32769, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
buf := make([]byte, 5)
|
||||
count, err := f.Read(buf)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if count != 5 || len(buf) < 5 {
|
||||
return false
|
||||
}
|
||||
if !(buf[0] == 0x43 && buf[1] == 0x44 &&
|
||||
buf[2] == 0x30 && buf[3] == 0x30 &&
|
||||
buf[4] == 0x31) {
|
||||
return false
|
||||
}
|
||||
_, err = f.Seek(0, 0)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (r *isoWVReader) open(path string) error {
|
||||
var err error
|
||||
r.f, err = os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = r.f.Close()
|
||||
r.f = nil
|
||||
}
|
||||
}()
|
||||
|
||||
if !isISO(r.f) {
|
||||
return ErrorNotAnISO
|
||||
}
|
||||
|
||||
r.i, err = iso9660.OpenImage(r.f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := r.i.RootDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
children, err := root.GetChildren()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range children {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".WV") {
|
||||
continue
|
||||
}
|
||||
r.r = entry.Reader()
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func openWV(path string) io.ReadCloser {
|
||||
reader := isoWVReader{}
|
||||
|
||||
if err := reader.open(path); err != nil {
|
||||
if !errors.Is(err, ErrorNotAnISO) {
|
||||
log.Error("Can't open ISO image file", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return &reader
|
||||
}
|
||||
|
||||
func isSubTrack(mf *model.MediaFile) bool {
|
||||
return mf.SubTrack > -1
|
||||
}
|
||||
|
||||
func openWVFromISO(path string) io.ReadCloser {
|
||||
if !(strings.HasSuffix(path, ".wv") || strings.HasSuffix(path, ".iso.wv")) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return openWV(path)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
38
db/migrations/20251231024638_support_media_with_subtracks.go
Normal file
38
db/migrations/20251231024638_support_media_with_subtracks.go
Normal file
@ -0,0 +1,38 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upSupportMediaWithSubtracks, downSupportMediaWithSubtracks)
|
||||
}
|
||||
|
||||
func upSupportMediaWithSubtracks(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
-- Add offset and sub_track columns
|
||||
ALTER TABLE media_file ADD COLUMN offset real DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE media_file ADD COLUMN sub_track integer DEFAULT -1 NOT NULL;
|
||||
ALTER TABLE media_file ADD COLUMN cuefile varchar(255) DEFAULT '' NOT NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func downSupportMediaWithSubtracks(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
-- Remove data for sub tracks
|
||||
DELETE FROM media_file WHERE sub_track != -1;
|
||||
-- Remove offset and sub_track columns
|
||||
ALTER TABLE media_file DROP COLUMN offset DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE media_file DROP COLUMN sub_track integer DEFAULT -1 NOT NULL;
|
||||
ALTER TABLE media_file DROP COLUMN cuefile varchar(255) DEFAULT '' NOT NULL;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() {
|
||||
|
||||
756
model/metadata/cue/cuesheet.go
Normal file
756
model/metadata/cue/cuesheet.go
Normal file
@ -0,0 +1,756 @@
|
||||
package cue
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const (
|
||||
delims = "\t\n\r "
|
||||
framesPerSecond = 75 // is based on audio CD sectors and 44100 Hz sample rate
|
||||
remGenre = "GENRE"
|
||||
remComment = "COMMENT"
|
||||
remDate = "DATE"
|
||||
remDiskID = "DISCID"
|
||||
remRGAlbumGain = "REPLAYGAIN_ALBUM_GAIN"
|
||||
remRGAlbumPeak = "REPLAYGAIN_ALBUM_PEAK"
|
||||
remRGTrackGain = "REPLAYGAIN_TRACK_GAIN"
|
||||
remRGTrackPeak = "REPLAYGAIN_TRACK_PEAK"
|
||||
remDiscNumber = "DISCNUMBER"
|
||||
remTotalDiscs = "TOTALDISCS"
|
||||
)
|
||||
|
||||
type ExpectedFields int
|
||||
|
||||
const (
|
||||
ExpectCommon ExpectedFields = iota
|
||||
ExpectTracks
|
||||
ExpectTrack
|
||||
)
|
||||
|
||||
// Frame represent track frame
|
||||
type Frame uint64
|
||||
|
||||
// Flags for track
|
||||
type Flags int
|
||||
|
||||
// RemData data from REM lines
|
||||
type RemData map[string]string
|
||||
|
||||
const (
|
||||
// None - no flags
|
||||
None Flags = iota
|
||||
Dcp = 1 << iota
|
||||
FourCh = 1 << iota
|
||||
Pre = 1 << iota
|
||||
Scms = 1 << iota
|
||||
)
|
||||
|
||||
var (
|
||||
ErrorParseCUE = fmt.Errorf("no CUE data")
|
||||
ErrorFrameFormat = fmt.Errorf("invalid frame format")
|
||||
ErrorInvalidISRC = fmt.Errorf("invalid ISRC")
|
||||
ErrorInvalidCatalog = fmt.Errorf("invalid CATALOG")
|
||||
ErrorInvalidText = fmt.Errorf("invalid text")
|
||||
ErrorUnclosedQuote = fmt.Errorf("unclosed quote in string")
|
||||
ErrorMissingFile = fmt.Errorf("no FILE")
|
||||
ErrorMissingIndex = fmt.Errorf("no INDEX")
|
||||
ErrorMissingTrack = fmt.Errorf("no TRACK")
|
||||
ErrorTrackOutOfOrder = fmt.Errorf("track out of order")
|
||||
ErrorIndexOutOfOrder = fmt.Errorf("index out of order")
|
||||
ErrorExpectedTrackIndent = fmt.Errorf("expected track indent")
|
||||
ErrorDuplicateCatalog = fmt.Errorf("duplicate CATALOG")
|
||||
ErrorDuplicateCdTextFile = fmt.Errorf("duplicate CDTEXTFILE")
|
||||
ErrorDuplicatePerformer = fmt.Errorf("duplicate PERFORMER")
|
||||
ErrorDuplicateTitle = fmt.Errorf("duplicate TITLE")
|
||||
ErrorDuplicateSongwriter = fmt.Errorf("duplicate SONGWRITER")
|
||||
ErrorDuplicateTrackPerformer = fmt.Errorf("duplicate track PERFORMER")
|
||||
ErrorDuplicateTrackTitle = fmt.Errorf("duplicate track TITLE")
|
||||
ErrorDuplicateTrackSongwriter = fmt.Errorf("duplicate track SONGWRITER")
|
||||
ErrorDuplicateTrackFlags = fmt.Errorf("duplicate track FLAGS")
|
||||
ErrorDuplicateTrackISRC = fmt.Errorf("duplicate track ISRC")
|
||||
ErrorDuplicateTrackPostGap = fmt.Errorf("duplicate track POSTGAP")
|
||||
ErrorDuplicateTrackPreGap = fmt.Errorf("duplicate track PREGAP")
|
||||
)
|
||||
|
||||
var (
|
||||
catalogRegex = regexp.MustCompile(`^\d{13}$`)
|
||||
isrcRegex = regexp.MustCompile(`^[\da-zA-Z]{12}$`)
|
||||
cueRegex = regexp.MustCompile(`^\S+( )+\S+.+`)
|
||||
)
|
||||
|
||||
// TrackIndex data
|
||||
type TrackIndex struct {
|
||||
Number uint
|
||||
Frame Frame
|
||||
}
|
||||
|
||||
func frameFromString(str string) (Frame, error) {
|
||||
v := strings.Split(str, ":")
|
||||
if len(str) < 8 {
|
||||
return 0, ErrorFrameFormat
|
||||
}
|
||||
if len(v) == 3 {
|
||||
mm, _ := strconv.ParseUint(v[0], 10, 32)
|
||||
ss, _ := strconv.ParseUint(v[1], 10, 32)
|
||||
if ss > 59 {
|
||||
return 0, ErrorFrameFormat
|
||||
}
|
||||
ff, _ := strconv.ParseUint(v[2], 10, 32)
|
||||
if ff >= framesPerSecond {
|
||||
return 0, ErrorFrameFormat
|
||||
}
|
||||
return Frame((mm*60+ss)*framesPerSecond + ff), nil
|
||||
}
|
||||
return 0, ErrorFrameFormat
|
||||
}
|
||||
|
||||
func (frame Frame) Duration() time.Duration {
|
||||
seconds := time.Second * time.Duration(frame/framesPerSecond)
|
||||
milliseconds := time.Millisecond * time.Duration(float64(frame%framesPerSecond)/0.075)
|
||||
return seconds + milliseconds
|
||||
}
|
||||
|
||||
func (frame Frame) String() string {
|
||||
seconds := frame / framesPerSecond
|
||||
minutes := seconds / 60
|
||||
seconds %= 60
|
||||
ff := frame % framesPerSecond
|
||||
return fmt.Sprintf("%.2d:%.2d:%.2d", minutes, seconds, ff)
|
||||
}
|
||||
|
||||
// Track instance
|
||||
type Track struct {
|
||||
Rem RemData
|
||||
TrackNumber uint
|
||||
TrackDataType string
|
||||
Flags Flags
|
||||
ISRC string
|
||||
Title string
|
||||
Performer string
|
||||
SongWriter string
|
||||
PreGap Frame
|
||||
PostGap Frame
|
||||
Index []TrackIndex
|
||||
}
|
||||
|
||||
// File instance
|
||||
type File struct {
|
||||
FileName string
|
||||
FileType string
|
||||
Tracks []Track
|
||||
}
|
||||
|
||||
// Cuesheet instance
|
||||
type Cuesheet struct {
|
||||
Rem RemData
|
||||
Catalog string
|
||||
CdTextFile string
|
||||
Title string
|
||||
Performer string
|
||||
SongWriter string
|
||||
Pregap Frame
|
||||
Postgap Frame
|
||||
File []File
|
||||
}
|
||||
|
||||
func (track *Track) GetStartOffset() time.Duration {
|
||||
if len(track.Index) > 1 && track.Index[0].Number == 0 {
|
||||
return track.Index[1].Frame.Duration()
|
||||
}
|
||||
return track.Index[0].Frame.Duration()
|
||||
}
|
||||
|
||||
func ReadFromFileFS(fs fs.FS, filePath string) (*Cuesheet, error) {
|
||||
file, err := fs.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Error("Can't close CUE file", "filePath", filePath)
|
||||
}
|
||||
}()
|
||||
return ReadCue(file)
|
||||
}
|
||||
|
||||
// ReadFromFile load CUESheet from file
|
||||
func ReadFromFile(filePath string) (*Cuesheet, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Error("Can't close CUE file", "filePath", filePath)
|
||||
}
|
||||
}()
|
||||
return ReadCue(file)
|
||||
}
|
||||
|
||||
func setTagOrError(value *string, newValue string, err error, noEmpty bool) error {
|
||||
if len(*value) > 0 {
|
||||
return err
|
||||
}
|
||||
*value = newValue
|
||||
if noEmpty && *value == "" {
|
||||
return ErrorInvalidText
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCUERem(cuesheet *Cuesheet, line string) error {
|
||||
if cuesheet.Rem == nil {
|
||||
cuesheet.Rem = RemData{}
|
||||
}
|
||||
key, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("REM command key: %w", err)
|
||||
}
|
||||
trimmedLine := strings.TrimLeft(line, delims)
|
||||
if len(trimmedLine) > 0 && isQuoted(trimmedLine) {
|
||||
value, closed := unquote(trimmedLine)
|
||||
if !closed {
|
||||
return ErrorUnclosedQuote
|
||||
}
|
||||
cuesheet.Rem[key] = value
|
||||
} else {
|
||||
cuesheet.Rem[key] = line
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCUECatalog(cuesheet *Cuesheet, line string) error {
|
||||
if len(cuesheet.Catalog) > 0 {
|
||||
return ErrorDuplicateCatalog
|
||||
}
|
||||
if !catalogRegex.MatchString(line) {
|
||||
return ErrorInvalidCatalog
|
||||
}
|
||||
cuesheet.Catalog = line
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCUECdTextFile(cuesheet *Cuesheet, line string) error {
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
log.Warn("Unclosed quote in CDTEXTFILE, using rest of line", "value", value)
|
||||
}
|
||||
return setTagOrError(&cuesheet.CdTextFile, value, ErrorDuplicateCdTextFile, false)
|
||||
}
|
||||
|
||||
func readCUETitle(cuesheet *Cuesheet, line string) error {
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
log.Warn("Unclosed quote in TITLE, using rest of line", "value", value)
|
||||
}
|
||||
err = setTagOrError(&cuesheet.Title, value, ErrorDuplicateTitle, true)
|
||||
if errors.Is(err, ErrorDuplicateTitle) {
|
||||
log.Error(fmt.Sprintf("already has title '%s' / '%s'", cuesheet.Title, line))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func readCUEPerformer(cuesheet *Cuesheet, line string) error {
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
log.Warn("Unclosed quote in PERFORMER, using rest of line", "value", value)
|
||||
}
|
||||
return setTagOrError(&cuesheet.Performer, value, ErrorDuplicatePerformer, true)
|
||||
}
|
||||
|
||||
func readCUESongwriter(cuesheet *Cuesheet, line string) error {
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
log.Warn("Unclosed quote in SONGWRITER, using rest of line", "value", value)
|
||||
}
|
||||
return setTagOrError(&cuesheet.SongWriter, value, ErrorDuplicateSongwriter, true)
|
||||
}
|
||||
|
||||
func readCUEPreGap(cuesheet *Cuesheet, line string) error {
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PREGAP: %w", err)
|
||||
}
|
||||
cuesheet.Pregap, err = frameFromString(value)
|
||||
return err
|
||||
}
|
||||
|
||||
func readCUEPostGap(cuesheet *Cuesheet, line string) error {
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POSTGAP: %w", err)
|
||||
}
|
||||
cuesheet.Postgap, err = frameFromString(value)
|
||||
return err
|
||||
}
|
||||
|
||||
func readCUEFile(cuesheet *Cuesheet, line string) error {
|
||||
fileName, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FILE command filename: %w", err)
|
||||
}
|
||||
fileType, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FILE command type: %w", err)
|
||||
}
|
||||
cuesheet.File = append(cuesheet.File, File{FileName: fileName, FileType: fileType})
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCUEFields(cuesheet *Cuesheet, line string) error {
|
||||
command, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading command: %w", err)
|
||||
}
|
||||
command = strings.ToUpper(command)
|
||||
|
||||
switch command {
|
||||
case "REM":
|
||||
return readCUERem(cuesheet, line)
|
||||
case "CATALOG":
|
||||
return readCUECatalog(cuesheet, line)
|
||||
case "CDTEXTFILE":
|
||||
return readCUECdTextFile(cuesheet, line)
|
||||
case "TITLE":
|
||||
return readCUETitle(cuesheet, line)
|
||||
case "PERFORMER":
|
||||
return readCUEPerformer(cuesheet, line)
|
||||
case "SONGWRITER":
|
||||
return readCUESongwriter(cuesheet, line)
|
||||
case "PREGAP":
|
||||
return readCUEPreGap(cuesheet, line)
|
||||
case "POSTGAP":
|
||||
return readCUEPostGap(cuesheet, line)
|
||||
case "FILE":
|
||||
return readCUEFile(cuesheet, line)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readFileFields(file *File, line string) (bool, error) {
|
||||
command, err := readString(&line)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reading command: %w", err)
|
||||
}
|
||||
command = strings.ToUpper(command)
|
||||
|
||||
switch command {
|
||||
case "TRACK":
|
||||
track := Track{}
|
||||
track.TrackNumber, err = readUint(&line)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("TRACK number: %w", err)
|
||||
}
|
||||
if len(file.Tracks) != int(track.TrackNumber)-1 {
|
||||
return false, ErrorTrackOutOfOrder
|
||||
}
|
||||
track.TrackDataType, err = readString(&line)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("TRACK data type: %w", err)
|
||||
}
|
||||
file.Tracks = append(file.Tracks, track)
|
||||
return true, nil
|
||||
default:
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func parseFlag(flag string) Flags {
|
||||
switch flag {
|
||||
case "DCP":
|
||||
return Dcp
|
||||
case "4CH":
|
||||
return FourCh
|
||||
case "PRE":
|
||||
return Pre
|
||||
case "SCMS":
|
||||
return Scms
|
||||
default:
|
||||
return None
|
||||
}
|
||||
}
|
||||
|
||||
func readTrackFlags(track *Track, line string) error {
|
||||
if track.Flags != None {
|
||||
return ErrorDuplicateTrackFlags
|
||||
}
|
||||
track.Flags = None
|
||||
for len(line) > 0 {
|
||||
flag, err := readString(&line)
|
||||
if err != nil {
|
||||
log.Warn("Unclosed quote in FLAGS, using rest of line", "value", flag)
|
||||
if flag != "" {
|
||||
track.Flags |= parseFlag(flag)
|
||||
}
|
||||
break
|
||||
}
|
||||
track.Flags |= parseFlag(flag)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readTrackISRC(track *Track, line string) error {
|
||||
if len(track.ISRC) > 0 {
|
||||
return ErrorDuplicateTrackISRC
|
||||
}
|
||||
if !isrcRegex.MatchString(line) {
|
||||
return ErrorInvalidISRC
|
||||
}
|
||||
track.ISRC = line
|
||||
return nil
|
||||
}
|
||||
|
||||
func readTrackStringField(value *string, line string, duplicateErr error, fieldName string) error {
|
||||
v, closed := unquote(line)
|
||||
if !closed {
|
||||
log.Warn(fmt.Sprintf("Unclosed quote in track %s, using rest of line", fieldName), "value", v)
|
||||
}
|
||||
return setTagOrError(value, v, duplicateErr, true)
|
||||
}
|
||||
|
||||
func readTrackPreGap(track *Track, line string) error {
|
||||
if track.PreGap > 0 {
|
||||
return ErrorDuplicateTrackPreGap
|
||||
}
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PREGAP: %w", err)
|
||||
}
|
||||
track.PreGap, err = frameFromString(value)
|
||||
return err
|
||||
}
|
||||
|
||||
func readTrackPostGap(track *Track, line string) error {
|
||||
if track.PostGap > 0 {
|
||||
return ErrorDuplicateTrackPostGap
|
||||
}
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POSTGAP: %w", err)
|
||||
}
|
||||
track.PostGap, err = frameFromString(value)
|
||||
return err
|
||||
}
|
||||
|
||||
func readTrackIndex(track *Track, line string) error {
|
||||
index := TrackIndex{}
|
||||
var err error
|
||||
index.Number, err = readUint(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("INDEX number: %w", err)
|
||||
}
|
||||
value, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("INDEX frame: %w", err)
|
||||
}
|
||||
index.Frame, err = frameFromString(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(track.Index) == 0 && index.Number > 1 {
|
||||
return ErrorIndexOutOfOrder
|
||||
} else if len(track.Index) > 0 && track.Index[len(track.Index)-1].Number != index.Number-1 {
|
||||
return ErrorIndexOutOfOrder
|
||||
}
|
||||
track.Index = append(track.Index, index)
|
||||
return nil
|
||||
}
|
||||
|
||||
func readTrackRem(track *Track, line string) error {
|
||||
if track.Rem == nil {
|
||||
track.Rem = RemData{}
|
||||
}
|
||||
key, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("REM command key: %w", err)
|
||||
}
|
||||
trimmedLine := strings.TrimLeft(line, delims)
|
||||
if len(trimmedLine) > 0 && isQuoted(trimmedLine) {
|
||||
value, closed := unquote(trimmedLine)
|
||||
if !closed {
|
||||
return ErrorUnclosedQuote
|
||||
}
|
||||
track.Rem[key] = value
|
||||
} else {
|
||||
track.Rem[key] = line
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readTrackFields(track *Track, line string) error {
|
||||
command, err := readString(&line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading command: %w", err)
|
||||
}
|
||||
command = strings.ToUpper(command)
|
||||
|
||||
switch command {
|
||||
case "FLAGS":
|
||||
return readTrackFlags(track, line)
|
||||
case "ISRC":
|
||||
return readTrackISRC(track, line)
|
||||
case "TITLE":
|
||||
return readTrackStringField(&track.Title, line, ErrorDuplicateTrackTitle, "TITLE")
|
||||
case "PERFORMER":
|
||||
return readTrackStringField(&track.Performer, line, ErrorDuplicateTrackPerformer, "PERFORMER")
|
||||
case "SONGWRITER":
|
||||
return readTrackStringField(&track.SongWriter, line, ErrorDuplicateTrackSongwriter, "SONGWRITER")
|
||||
case "PREGAP":
|
||||
return readTrackPreGap(track, line)
|
||||
case "POSTGAP":
|
||||
return readTrackPostGap(track, line)
|
||||
case "INDEX":
|
||||
return readTrackIndex(track, line)
|
||||
case "REM":
|
||||
return readTrackRem(track, line)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ReadCue loads and parses CUESHEET from reader
|
||||
func ReadCue(r io.Reader) (*Cuesheet, error) {
|
||||
s := bufio.NewScanner(r)
|
||||
cuesheet := &Cuesheet{}
|
||||
|
||||
firstLine := true
|
||||
var fields ExpectedFields
|
||||
trackJustInserted := false
|
||||
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
|
||||
if firstLine {
|
||||
firstLine = false
|
||||
if !cueRegex.MatchString(line) {
|
||||
return nil, ErrorParseCUE
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, " ") {
|
||||
line = line[4:]
|
||||
fields = ExpectTrack
|
||||
trackJustInserted = false
|
||||
} else if strings.HasPrefix(line, " ") {
|
||||
line = line[2:]
|
||||
fields = ExpectTracks
|
||||
if trackJustInserted {
|
||||
return nil, ErrorExpectedTrackIndent
|
||||
}
|
||||
} else {
|
||||
fields = ExpectCommon
|
||||
if trackJustInserted {
|
||||
return nil, ErrorExpectedTrackIndent
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch fields {
|
||||
case ExpectCommon:
|
||||
err = readCUEFields(cuesheet, line)
|
||||
case ExpectTracks:
|
||||
if len(cuesheet.File) == 0 {
|
||||
return nil, ErrorMissingFile
|
||||
}
|
||||
trackJustInserted, err = readFileFields(&cuesheet.File[len(cuesheet.File)-1], line)
|
||||
case ExpectTrack:
|
||||
if len(cuesheet.File) == 0 {
|
||||
return nil, ErrorMissingFile
|
||||
}
|
||||
tracks := cuesheet.File[len(cuesheet.File)-1].Tracks
|
||||
if len(tracks) == 0 {
|
||||
return nil, ErrorMissingTrack
|
||||
}
|
||||
err = readTrackFields(&tracks[len(tracks)-1], line)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(cuesheet.File) == 0 {
|
||||
return nil, ErrorMissingFile
|
||||
}
|
||||
|
||||
for _, file := range cuesheet.File {
|
||||
if len(file.Tracks) == 0 {
|
||||
return nil, ErrorMissingTrack
|
||||
}
|
||||
for _, track := range file.Tracks {
|
||||
if len(track.Index) == 0 || (len(track.Index) == 1 && track.Index[0].Number == 0) {
|
||||
return nil, ErrorMissingIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cuesheet, nil
|
||||
}
|
||||
|
||||
func readString(s *string) (string, error) {
|
||||
*s = strings.TrimLeft(*s, delims)
|
||||
|
||||
if len(*s) > 0 && isQuoted(*s) {
|
||||
v, closed := unquote(*s)
|
||||
if !closed {
|
||||
return v, ErrorUnclosedQuote
|
||||
}
|
||||
*s = (*s)[len(v)+2:]
|
||||
return v, nil
|
||||
}
|
||||
for i := 0; i < len(*s); i++ {
|
||||
if (*s)[i] == ' ' {
|
||||
v := (*s)[0:i]
|
||||
*s = (*s)[i+1:]
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
v := *s
|
||||
*s = ""
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func readUint(s *string) (uint, error) {
|
||||
v, err := readString(s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number: %w", err)
|
||||
}
|
||||
return uint(n), nil
|
||||
}
|
||||
|
||||
func isQuoted(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
return s[0] == '"' || s[0] == '\''
|
||||
}
|
||||
|
||||
func unquote(s string) (string, bool) {
|
||||
if len(s) == 0 || !isQuoted(s) {
|
||||
return s, true
|
||||
}
|
||||
|
||||
quote := s[0]
|
||||
i := 1
|
||||
for ; i < len(s); i++ {
|
||||
if s[i] == quote {
|
||||
return s[1:i], true
|
||||
}
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return s[1:], false
|
||||
}
|
||||
|
||||
func (rem *RemData) DiscNumber() int {
|
||||
s, ok := (*rem)[remDiscNumber]
|
||||
if ok {
|
||||
result, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rem *RemData) TotalDiscs() int {
|
||||
s, ok := (*rem)[remTotalDiscs]
|
||||
if ok {
|
||||
result, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Genre returns genre from data
|
||||
func (rem *RemData) Genre() string {
|
||||
s, ok := (*rem)[remGenre]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Comment returns comment field
|
||||
func (rem *RemData) Comment() string {
|
||||
s, ok := (*rem)[remComment]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DiskID returns disk id from data
|
||||
func (rem *RemData) DiskID() string {
|
||||
s, ok := (*rem)[remDiskID]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Date returns release year
|
||||
func (rem *RemData) Date() string {
|
||||
s, ok := (*rem)[remDate]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AlbumGain returns album replay gain value
|
||||
func (rem *RemData) AlbumGain() string {
|
||||
s, ok := (*rem)[remRGAlbumGain]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AlbumPeak returns album replay gain peak value
|
||||
func (rem *RemData) AlbumPeak() string {
|
||||
s, ok := (*rem)[remRGAlbumPeak]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TrackGain returns track replay gain value
|
||||
func (rem *RemData) TrackGain() string {
|
||||
s, ok := (*rem)[remRGTrackGain]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TrackPeak returns track replay gain peak value
|
||||
func (rem *RemData) TrackPeak() string {
|
||||
s, ok := (*rem)[remRGTrackPeak]
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
347
model/metadata/cue/cuesheet_test.go
Normal file
347
model/metadata/cue/cuesheet_test.go
Normal file
@ -0,0 +1,347 @@
|
||||
package cue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFrame_Duration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input Frame
|
||||
output time.Duration
|
||||
}{
|
||||
{
|
||||
input: Frame(0),
|
||||
output: time.Duration(0),
|
||||
},
|
||||
{
|
||||
input: Frame(1),
|
||||
output: time.Millisecond * 13,
|
||||
},
|
||||
{
|
||||
input: Frame(74),
|
||||
output: time.Millisecond * 986,
|
||||
},
|
||||
{
|
||||
input: Frame(75),
|
||||
output: time.Second,
|
||||
},
|
||||
{
|
||||
input: Frame(76),
|
||||
output: time.Second + time.Millisecond*13,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input.String(), func(t *testing.T) {
|
||||
require.Equal(t, test.output, test.input.Duration())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameFromStringToString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
output Frame
|
||||
err error
|
||||
}{
|
||||
{
|
||||
input: "14:15:70",
|
||||
output: Frame(0xfac3),
|
||||
},
|
||||
{
|
||||
input: "0:0:0",
|
||||
err: ErrorFrameFormat,
|
||||
},
|
||||
{
|
||||
input: "00:00:00",
|
||||
output: Frame(0),
|
||||
},
|
||||
{
|
||||
input: "00:00:10",
|
||||
output: Frame(10),
|
||||
},
|
||||
{
|
||||
input: "00:01:74",
|
||||
output: Frame(0x95),
|
||||
},
|
||||
{
|
||||
input: "155:01:01",
|
||||
output: Frame(0xaa4e8),
|
||||
},
|
||||
{
|
||||
input: "02:59:01",
|
||||
output: Frame(0x3472),
|
||||
},
|
||||
{
|
||||
input: "01:01:01",
|
||||
output: Frame(0x11e0),
|
||||
},
|
||||
{
|
||||
input: "00:01:75",
|
||||
output: Frame(0),
|
||||
err: ErrorFrameFormat,
|
||||
},
|
||||
{
|
||||
input: "invalid",
|
||||
output: Frame(0),
|
||||
err: ErrorFrameFormat,
|
||||
},
|
||||
{
|
||||
input: "001:60:73",
|
||||
output: Frame(0),
|
||||
err: ErrorFrameFormat,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
result, err := frameFromString(test.input)
|
||||
if test.err != nil {
|
||||
require.EqualError(t, err, test.err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.output, result)
|
||||
require.Equal(t, test.input, result.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCueReader(t *testing.T) {
|
||||
tests := []struct {
|
||||
inputFile string
|
||||
check func(output *Cuesheet)
|
||||
error error
|
||||
}{
|
||||
{
|
||||
inputFile: "parser-case-insensitivity.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.NotEmpty(t, output.Performer)
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-comments.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
cueRem := output.Rem
|
||||
assert.NotEmpty(t, cueRem)
|
||||
assert.Contains(t, cueRem, "Foo")
|
||||
assert.Contains(t, cueRem, "Bar")
|
||||
assert.Contains(t, cueRem, "Yay!")
|
||||
trackRem := output.File[0].Tracks[0].Rem
|
||||
assert.NotEmpty(t, trackRem)
|
||||
assert.Contains(t, trackRem, "Doox")
|
||||
assert.Contains(t, trackRem, "Goox")
|
||||
assert.Contains(t, trackRem, "Loox")
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-wrong-track-indent.cue",
|
||||
error: ErrorExpectedTrackIndent,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-catalog.cue",
|
||||
error: ErrorDuplicateCatalog,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-cdtextfile.cue",
|
||||
error: ErrorDuplicateCdTextFile,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-performer.cue",
|
||||
error: ErrorDuplicatePerformer,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-songwriter.cue",
|
||||
error: ErrorDuplicateSongwriter,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-title.cue",
|
||||
error: ErrorDuplicateTitle,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-track-flags.cue",
|
||||
error: ErrorDuplicateTrackFlags,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-track-isrc.cue",
|
||||
error: ErrorDuplicateTrackISRC,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-track-performer.cue",
|
||||
error: ErrorDuplicateTrackPerformer,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-track-postgap.cue",
|
||||
error: ErrorDuplicateTrackPostGap,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-track-pregap.cue",
|
||||
error: ErrorDuplicateTrackPreGap,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-track-songwriter.cue",
|
||||
error: ErrorDuplicateTrackSongwriter,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-duplicate-track-title.cue",
|
||||
error: ErrorDuplicateTrackTitle,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-index-out-of-order.cue",
|
||||
error: ErrorIndexOutOfOrder,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-invalid-catalog.cue",
|
||||
error: ErrorInvalidCatalog,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-invalid-frames.cue",
|
||||
error: ErrorFrameFormat,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-invalid-isrc.cue",
|
||||
error: ErrorInvalidISRC,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-invalid-seconds.cue",
|
||||
error: ErrorFrameFormat,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-invalid-text.cue",
|
||||
error: ErrorInvalidText,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-missing-file.cue",
|
||||
error: ErrorMissingFile,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-missing-index.cue",
|
||||
error: ErrorMissingIndex,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-missing-track.cue",
|
||||
error: ErrorMissingTrack,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-track-out-of-order.cue",
|
||||
error: ErrorTrackOutOfOrder,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-track-out-of-order-2.cue",
|
||||
error: ErrorTrackOutOfOrder,
|
||||
},
|
||||
{
|
||||
inputFile: "test.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, "Into The Otherworld", output.Title)
|
||||
assert.Equal(t, 1, len(output.File))
|
||||
assert.Equal(t, 11, len(output.File[0].Tracks))
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "test_1.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, "When All Is Said: The Best of Edge of Sanity", output.Title)
|
||||
assert.Equal(t, 1, len(output.File))
|
||||
assert.Equal(t, 99, len(output.File[0].Tracks))
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "invalid.binary",
|
||||
error: ErrorParseCUE,
|
||||
},
|
||||
// Recoverable errors
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-performer.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, "Pink Floyd", output.Performer)
|
||||
assert.Equal(t, "The Dark Side of the Moon", output.Title)
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-title.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, "Greatest Hits", output.Title)
|
||||
assert.Equal(t, "Various Artists", output.Performer)
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-songwriter.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, "John Lennon", output.SongWriter)
|
||||
assert.Equal(t, "Artist Name", output.Performer)
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-track-title.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, 1, len(output.File[0].Tracks))
|
||||
assert.Equal(t, "Song Title Without Closing Quote", output.File[0].Tracks[0].Title)
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-track-performer.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, 1, len(output.File[0].Tracks))
|
||||
assert.Equal(t, "Track Artist", output.File[0].Tracks[0].Performer)
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-track-songwriter.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, 1, len(output.File[0].Tracks))
|
||||
assert.Equal(t, "Paul McCartney", output.File[0].Tracks[0].SongWriter)
|
||||
},
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-cdtextfile.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, "cdtext.cdt", output.CdTextFile)
|
||||
assert.Equal(t, "Artist", output.Performer)
|
||||
},
|
||||
},
|
||||
// Unrecoverable errors
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-file.cue",
|
||||
error: ErrorUnclosedQuote,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-file-type.cue",
|
||||
error: ErrorUnclosedQuote,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-rem-key.cue",
|
||||
error: ErrorUnclosedQuote,
|
||||
},
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-rem-value.cue",
|
||||
error: ErrorUnclosedQuote,
|
||||
},
|
||||
// Special cases
|
||||
{
|
||||
inputFile: "parser-unclosed-quote-escaped-end.cue",
|
||||
check: func(output *Cuesheet) {
|
||||
assert.Equal(t, `Artist Name\"`, output.Performer)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.inputFile, func(t *testing.T) {
|
||||
cue, err := ReadFromFile(filepath.Join("testdata", test.inputFile))
|
||||
if test.error != nil {
|
||||
assert.ErrorContains(t, err, test.error.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cue)
|
||||
assert.NotNil(t, test.check)
|
||||
test.check(cue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
110
model/metadata/cue/tags_extractor.go
Normal file
110
model/metadata/cue/tags_extractor.go
Normal file
@ -0,0 +1,110 @@
|
||||
package cue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
)
|
||||
|
||||
type TagsExtractor struct {
|
||||
md *metadata.Metadata
|
||||
}
|
||||
|
||||
func NewExtractor(md *metadata.Metadata) (*TagsExtractor, error) {
|
||||
return &TagsExtractor{
|
||||
md: md,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func makeDuration(d time.Duration) string {
|
||||
return strconv.FormatFloat(d.Seconds(), 'f', 3, 32)
|
||||
}
|
||||
|
||||
func (e *TagsExtractor) Extract(cueSheet *Cuesheet, cueFile string) ([]model.RawTags, error) {
|
||||
result := make([]model.RawTags, 0)
|
||||
|
||||
for _, file := range cueSheet.File {
|
||||
if cueFile != "" && file.FileName != path.Base(e.md.FilePath()) {
|
||||
continue
|
||||
}
|
||||
totalTracks := len(file.Tracks)
|
||||
lastTrack := totalTracks - 1
|
||||
for i, t := range file.Tracks {
|
||||
tags := make(model.RawTags)
|
||||
|
||||
addTagValue(model.TagCUEFile, tags, cueFile)
|
||||
addTagValue(model.TagTitle, tags, t.Title)
|
||||
addTagValue(model.TagCUESubTrack, tags, strconv.Itoa(i))
|
||||
addTagValue(model.TagTrackArtist, tags, t.Performer, cueSheet.Performer)
|
||||
addTagValue(model.TagAlbum, tags, cueSheet.Title)
|
||||
addTagValue(model.TagGenre, tags, cueSheet.Rem.Genre())
|
||||
if t.Rem.TotalDiscs() > 0 {
|
||||
if t.Rem.DiscNumber() > 0 {
|
||||
addTagValue(model.TagDiscNumber, tags, strconv.FormatUint(uint64(t.Rem.DiscNumber()), 10))
|
||||
}
|
||||
addTagValue(model.TagTotalDiscs, tags, strconv.FormatUint(uint64(t.Rem.TotalDiscs()), 10))
|
||||
}
|
||||
addTagValue(model.TagAlbumArtist, tags, cueSheet.Performer)
|
||||
addTagValue(model.TagComment, tags, t.Rem.Comment(), cueSheet.Rem.Comment())
|
||||
addTagValue(model.TagTrackNumber, tags, strconv.FormatUint(uint64(t.TrackNumber), 10))
|
||||
addTagValue(model.TagTotalTracks, tags, strconv.FormatUint(uint64(totalTracks), 10))
|
||||
addTagValue(model.TagReleaseDate, tags, cueSheet.Rem.Date())
|
||||
addTagValue(model.TagReplayGainAlbumGain, tags, cueSheet.Rem.AlbumGain())
|
||||
addTagValue(model.TagReplayGainAlbumPeak, tags, cueSheet.Rem.AlbumPeak())
|
||||
addTagValue(model.TagReplayGainTrackGain, tags, t.Rem.TrackGain())
|
||||
addTagValue(model.TagReplayGainTrackPeak, tags, t.Rem.TrackPeak())
|
||||
addTagValue(model.TagCUETrackOffset, tags, makeDuration(t.GetStartOffset()))
|
||||
addTagValue(model.TagISRC, tags, t.ISRC)
|
||||
|
||||
var length time.Duration
|
||||
if i < lastTrack {
|
||||
nextTrack := file.Tracks[i+1]
|
||||
length = nextTrack.GetStartOffset() - t.GetStartOffset()
|
||||
} else {
|
||||
fileDuration := time.Millisecond * time.Duration(e.md.Length()*1000.0)
|
||||
length = fileDuration - t.GetStartOffset()
|
||||
}
|
||||
if length < 0 {
|
||||
length = 0
|
||||
}
|
||||
addTagValue(model.TagCUETrackDuration, tags, makeDuration(length))
|
||||
|
||||
// Fallback to main metadata for missing tags
|
||||
addTagValue(model.TagAlbum, tags, e.md.String(model.TagAlbum))
|
||||
addTagValue(model.TagAlbumArtist, tags, e.md.String(model.TagAlbumArtist))
|
||||
addTagValue(model.TagReleaseDate, tags, e.md.String(model.TagReleaseDate))
|
||||
for _, genre := range e.md.Strings(model.TagGenre) {
|
||||
addTagValue(model.TagGenre, tags, genre)
|
||||
}
|
||||
addTagValue(model.TagMusicBrainzAlbumID, tags, e.md.String(model.TagMusicBrainzAlbumID))
|
||||
addTagValue(model.TagMusicBrainzArtistID, tags, e.md.String(model.TagMusicBrainzArtistID))
|
||||
addTagValue(model.TagMusicBrainzRecordingID, tags, e.md.String(model.TagMusicBrainzRecordingID))
|
||||
addTagValue(model.TagMusicBrainzTrackID, tags, e.md.String(model.TagMusicBrainzTrackID))
|
||||
for _, value := range e.md.CueTags()[fmt.Sprintf("cue_track%02d_musicbrainz_trackid", t.TrackNumber)] {
|
||||
addTagValue(model.TagMusicBrainzTrackID, tags, value)
|
||||
}
|
||||
disc, discs := e.md.NumAndTotal(model.TagDiscNumber)
|
||||
if disc > 0 && discs > 0 {
|
||||
addTagValue(model.TagDiscNumber, tags, strconv.FormatUint(uint64(disc), 10))
|
||||
addTagValue(model.TagTotalDiscs, tags, strconv.FormatUint(uint64(discs), 10))
|
||||
}
|
||||
|
||||
result = append(result, tags)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func addTagValue(name model.TagName, tags model.RawTags, values ...string) {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
tags[name.String()] = append(tags[name.String()], v)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
model/metadata/cue/testdata/invalid.binary
vendored
Normal file
BIN
model/metadata/cue/testdata/invalid.binary
vendored
Normal file
Binary file not shown.
9
model/metadata/cue/testdata/parser-case-insensitivity.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-case-insensitivity.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
perFORmer "Faithless"
|
||||
rem GENRE Electronica
|
||||
title "Live in Berlin"
|
||||
file "Faithless - Live in Berlin.mp3" MP3
|
||||
TRack 01 AUDIO
|
||||
TITLE "Reverence"
|
||||
performer "Faithless"
|
||||
Index 01 00:00:03
|
||||
inDex 02 00:02:01
|
||||
24
model/metadata/cue/testdata/parser-comments.cue
vendored
Normal file
24
model/metadata/cue/testdata/parser-comments.cue
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
REM Foo
|
||||
REM Foo
|
||||
PERFORMER "Faithless"
|
||||
REM Bar
|
||||
REM Bar
|
||||
TITLE "Live in Berlin"
|
||||
REM Baz
|
||||
REM Baz
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
REM Quux
|
||||
REM Quux
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Reverence"
|
||||
REM Doox
|
||||
REM Doox
|
||||
PERFORMER "Faithless"
|
||||
REM Loox
|
||||
REM Loox
|
||||
INDEX 01 00:00:03
|
||||
REM Goox
|
||||
REM Goox
|
||||
INDEX 02 00:02:01
|
||||
REM Yay!
|
||||
REM Yay!
|
||||
2
model/metadata/cue/testdata/parser-duplicate-catalog.cue
vendored
Normal file
2
model/metadata/cue/testdata/parser-duplicate-catalog.cue
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
CATALOG 0112223334445
|
||||
CATALOG 0112223334442
|
||||
2
model/metadata/cue/testdata/parser-duplicate-cdtextfile.cue
vendored
Normal file
2
model/metadata/cue/testdata/parser-duplicate-cdtextfile.cue
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
CDTEXTFILE my-file.txt
|
||||
CDTEXTFILE my-file.txt
|
||||
2
model/metadata/cue/testdata/parser-duplicate-performer.cue
vendored
Normal file
2
model/metadata/cue/testdata/parser-duplicate-performer.cue
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
PERFORMER "Captain Beefheart"
|
||||
PERFORMER "Captain Beefheart"
|
||||
2
model/metadata/cue/testdata/parser-duplicate-songwriter.cue
vendored
Normal file
2
model/metadata/cue/testdata/parser-duplicate-songwriter.cue
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
SONGWRITER Foobar
|
||||
SONGWRITER Foobar
|
||||
2
model/metadata/cue/testdata/parser-duplicate-title.cue
vendored
Normal file
2
model/metadata/cue/testdata/parser-duplicate-title.cue
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
TITLE "My title"
|
||||
TITLE "My title"
|
||||
9
model/metadata/cue/testdata/parser-duplicate-track-flags.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-duplicate-track-flags.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
FLAGS 4CH
|
||||
FLAGS 4CH
|
||||
PERFORMER "Faithless"
|
||||
INDEX 02 00:00:01
|
||||
9
model/metadata/cue/testdata/parser-duplicate-track-isrc.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-duplicate-track-isrc.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
ISRC aaabb1231231
|
||||
ISRC aaabb1231231
|
||||
PERFORMER "Faithless"
|
||||
INDEX 02 00:00:01
|
||||
8
model/metadata/cue/testdata/parser-duplicate-track-performer.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-duplicate-track-performer.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
PERFORMER "Faithless"
|
||||
INDEX 02 00:00:01
|
||||
9
model/metadata/cue/testdata/parser-duplicate-track-postgap.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-duplicate-track-postgap.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
INDEX 01 00:00:01
|
||||
POSTGAP 00:01:00
|
||||
POSTGAP 00:01:00
|
||||
9
model/metadata/cue/testdata/parser-duplicate-track-pregap.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-duplicate-track-pregap.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
PREGAP 00:01:00
|
||||
PREGAP 00:01:00
|
||||
INDEX 02 00:00:01
|
||||
9
model/metadata/cue/testdata/parser-duplicate-track-songwriter.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-duplicate-track-songwriter.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
SONGWRITER Boba
|
||||
SONGWRITER Boba
|
||||
PERFORMER "Faithless"
|
||||
INDEX 02 00:00:01
|
||||
9
model/metadata/cue/testdata/parser-duplicate-track-title.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-duplicate-track-title.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Foo"
|
||||
TITLE "Foo"
|
||||
PERFORMER "Faithless"
|
||||
INDEX 02 00:00:01
|
||||
7
model/metadata/cue/testdata/parser-index-out-of-order.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-index-out-of-order.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
INDEX 02 00:00:01
|
||||
1
model/metadata/cue/testdata/parser-invalid-catalog.cue
vendored
Normal file
1
model/metadata/cue/testdata/parser-invalid-catalog.cue
vendored
Normal file
@ -0,0 +1 @@
|
||||
CATALOG 123
|
||||
7
model/metadata/cue/testdata/parser-invalid-frames.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-invalid-frames.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
INDEX 01 00:00:77
|
||||
8
model/metadata/cue/testdata/parser-invalid-isrc.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-invalid-isrc.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
ISRC 123
|
||||
INDEX 01 00:00:00
|
||||
7
model/metadata/cue/testdata/parser-invalid-seconds.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-invalid-seconds.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
INDEX 01 00:61:00
|
||||
1
model/metadata/cue/testdata/parser-invalid-text.cue
vendored
Normal file
1
model/metadata/cue/testdata/parser-invalid-text.cue
vendored
Normal file
@ -0,0 +1 @@
|
||||
PERFORMER ""
|
||||
3
model/metadata/cue/testdata/parser-missing-file.cue
vendored
Normal file
3
model/metadata/cue/testdata/parser-missing-file.cue
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
7
model/metadata/cue/testdata/parser-missing-index.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-missing-index.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
INDEX 00 00:00:00
|
||||
4
model/metadata/cue/testdata/parser-missing-track.cue
vendored
Normal file
4
model/metadata/cue/testdata/parser-missing-track.cue
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
9
model/metadata/cue/testdata/parser-track-out-of-order-2.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-track-out-of-order-2.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
INDEX 01 00:00:00
|
||||
TRACK 03 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
9
model/metadata/cue/testdata/parser-track-out-of-order.cue
vendored
Normal file
9
model/metadata/cue/testdata/parser-track-out-of-order.cue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
PERFORMER "Faithless"
|
||||
REM GENRE Electronica
|
||||
TITLE "Live in Berlin"
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
TRACK 03 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
INDEX 01 00:00:00
|
||||
TRACK 02 AUDIO
|
||||
PERFORMER "Faithless"
|
||||
8
model/metadata/cue/testdata/parser-unclosed-quote-cdtextfile.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-unclosed-quote-cdtextfile.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
REM Testing unclosed quote in CDTEXTFILE
|
||||
CDTEXTFILE "cdtext.cdt
|
||||
PERFORMER "Artist"
|
||||
TITLE "Album"
|
||||
FILE "audio.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
7
model/metadata/cue/testdata/parser-unclosed-quote-escaped-end.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-unclosed-quote-escaped-end.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
REM Testing unclosed quote with escaped backslash at end
|
||||
PERFORMER "Artist Name\"
|
||||
TITLE "Album"
|
||||
FILE "audio.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
8
model/metadata/cue/testdata/parser-unclosed-quote-file-type.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-unclosed-quote-file-type.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
REM Testing FILE with both filename and type in one quoted string (edge case)
|
||||
PERFORMER "Artist"
|
||||
TITLE "Album"
|
||||
FILE "file.flac" WAVE
|
||||
FILE "file2.flac" "WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
7
model/metadata/cue/testdata/parser-unclosed-quote-file.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-unclosed-quote-file.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
REM Testing unclosed quote in FILE name - CRITICAL: type expected after
|
||||
PERFORMER "Artist"
|
||||
TITLE "Album"
|
||||
FILE "path/to/audio/file.flac WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
7
model/metadata/cue/testdata/parser-unclosed-quote-performer.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-unclosed-quote-performer.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
REM Testing unclosed quote in album PERFORMER
|
||||
PERFORMER "Pink Floyd
|
||||
TITLE "The Dark Side of the Moon"
|
||||
FILE "dsotm.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Speak to Me"
|
||||
INDEX 01 00:00:00
|
||||
8
model/metadata/cue/testdata/parser-unclosed-quote-rem-key.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-unclosed-quote-rem-key.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
REM Testing unclosed quote in REM key
|
||||
PERFORMER "Artist"
|
||||
TITLE "Album"
|
||||
REM "SPECIAL_KEY value here
|
||||
FILE "audio.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
8
model/metadata/cue/testdata/parser-unclosed-quote-rem-value.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-unclosed-quote-rem-value.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
REM Testing unclosed quote in REM value (after key)
|
||||
PERFORMER "Artist"
|
||||
TITLE "Album"
|
||||
REM GENRE "Progressive Rock
|
||||
FILE "audio.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
8
model/metadata/cue/testdata/parser-unclosed-quote-songwriter.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-unclosed-quote-songwriter.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
REM Testing unclosed quote in SONGWRITER
|
||||
PERFORMER "Artist Name"
|
||||
TITLE "Album Title"
|
||||
SONGWRITER "John Lennon
|
||||
FILE "album.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
7
model/metadata/cue/testdata/parser-unclosed-quote-title.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-unclosed-quote-title.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
REM Testing unclosed quote in album TITLE - end of line
|
||||
PERFORMER "Various Artists"
|
||||
TITLE "Greatest Hits
|
||||
FILE "album.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
INDEX 01 00:00:00
|
||||
8
model/metadata/cue/testdata/parser-unclosed-quote-track-performer.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-unclosed-quote-track-performer.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
REM Testing unclosed quote in track PERFORMER
|
||||
PERFORMER "Album Artist"
|
||||
TITLE "Album"
|
||||
FILE "audio.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
PERFORMER "Track Artist
|
||||
INDEX 01 00:00:00
|
||||
8
model/metadata/cue/testdata/parser-unclosed-quote-track-songwriter.cue
vendored
Normal file
8
model/metadata/cue/testdata/parser-unclosed-quote-track-songwriter.cue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
REM Testing unclosed quote in track SONGWRITER
|
||||
PERFORMER "Album Artist"
|
||||
TITLE "Album"
|
||||
FILE "audio.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Track One"
|
||||
SONGWRITER "Paul McCartney
|
||||
INDEX 01 00:00:00
|
||||
7
model/metadata/cue/testdata/parser-unclosed-quote-track-title.cue
vendored
Normal file
7
model/metadata/cue/testdata/parser-unclosed-quote-track-title.cue
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
REM Testing unclosed quote in track TITLE
|
||||
PERFORMER "Artist"
|
||||
TITLE "Album"
|
||||
FILE "audio.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Song Title Without Closing Quote
|
||||
INDEX 01 00:00:00
|
||||
26
model/metadata/cue/testdata/parser-wrong-track-indent.cue
vendored
Normal file
26
model/metadata/cue/testdata/parser-wrong-track-indent.cue
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
REM Foo
|
||||
REM Foo
|
||||
PERFORMER "Faithless"
|
||||
REM Bar
|
||||
REM Bar
|
||||
TITLE "Live in Berlin"
|
||||
REM Baz
|
||||
REM Baz
|
||||
FILE "Faithless - Live in Berlin.mp3" MP3
|
||||
REM Quux
|
||||
REM Quux
|
||||
TRACK 01 AUDIO
|
||||
REM Fuux
|
||||
REM Fuux
|
||||
TITLE "Reverence"
|
||||
REM Doox
|
||||
REM Doox
|
||||
PERFORMER "Faithless"
|
||||
REM Loox
|
||||
REM Loox
|
||||
INDEX 01 00:00:03
|
||||
REM Goox
|
||||
REM Goox
|
||||
INDEX 02 00:02:01
|
||||
REM Yay!
|
||||
REM Yay!
|
||||
82
model/metadata/cue/testdata/test.cue
vendored
Normal file
82
model/metadata/cue/testdata/test.cue
vendored
Normal file
@ -0,0 +1,82 @@
|
||||
REM GENRE Folk
|
||||
REM DATE 2005
|
||||
REM DISCID 9A0D690B
|
||||
REM COMMENT ExactAudioCopy v0.99pb1
|
||||
PERFORMER "Thanateros"
|
||||
TITLE "Into The Otherworld"
|
||||
REM REPLAYGAIN_ALBUM_GAIN -10.33 dB
|
||||
REM REPLAYGAIN_ALBUM_PEAK 1.000000
|
||||
FILE "Thanateros - Into The Otherworld.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Calling Ilyr"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.21 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 01 00:00:00
|
||||
TRACK 02 AUDIO
|
||||
TITLE "Follow"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -11.18 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 00 04:11:53
|
||||
INDEX 01 04:13:36
|
||||
TRACK 03 AUDIO
|
||||
TITLE "Siuil A Run"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -11.52 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 00 08:00:38
|
||||
INDEX 01 08:02:31
|
||||
TRACK 04 AUDIO
|
||||
TITLE "Maggy"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.65 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 01 11:22:59
|
||||
TRACK 05 AUDIO
|
||||
TITLE "I'm Changing"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.33 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 00 14:15:11
|
||||
INDEX 01 14:15:70
|
||||
TRACK 06 AUDIO
|
||||
TITLE "Song As A Kiss"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -8.34 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 01 20:46:23
|
||||
TRACK 07 AUDIO
|
||||
TITLE "No Rest"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -11.37 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 00 24:15:56
|
||||
INDEX 01 24:17:61
|
||||
TRACK 08 AUDIO
|
||||
TITLE "Dirty Old Town"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.82 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 01 28:31:15
|
||||
TRACK 09 AUDIO
|
||||
TITLE "In Time"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.97 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 00 32:37:52
|
||||
INDEX 01 32:40:01
|
||||
TRACK 10 AUDIO
|
||||
TITLE "Four Winds"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.76 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 00 36:46:67
|
||||
INDEX 01 36:49:17
|
||||
TRACK 11 AUDIO
|
||||
TITLE "Привет"
|
||||
ISRC 000000000000
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.91 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.000000
|
||||
INDEX 00 42:12:17
|
||||
INDEX 01 42:14:74
|
||||
505
model/metadata/cue/testdata/test_1.cue
vendored
Normal file
505
model/metadata/cue/testdata/test_1.cue
vendored
Normal file
@ -0,0 +1,505 @@
|
||||
REM GENRE Death Metal
|
||||
REM DATE 2006
|
||||
REM DISCID E912C263
|
||||
REM COMMENT ExactAudioCopy v1.0b1
|
||||
PERFORMER "Edge of Sanity"
|
||||
TITLE "When All Is Said: The Best of Edge of Sanity"
|
||||
REM REPLAYGAIN_ALBUM_GAIN -8.32 dB
|
||||
REM REPLAYGAIN_ALBUM_PEAK 1.150353
|
||||
FILE "Edge of Sanity - When All Is Said (CD2).flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Crimson, Part 1"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.16 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.117337
|
||||
INDEX 01 00:00:00
|
||||
TRACK 02 AUDIO
|
||||
TITLE "Crimson, Part 2"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.38 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.082512
|
||||
INDEX 01 00:38:16
|
||||
TRACK 03 AUDIO
|
||||
TITLE "Crimson, Part 3"
|
||||
REM REPLAYGAIN_TRACK_GAIN -11.02 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.114110
|
||||
INDEX 01 01:55:15
|
||||
TRACK 04 AUDIO
|
||||
TITLE "Crimson, Part 4"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.59 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.082862
|
||||
INDEX 01 02:42:11
|
||||
TRACK 05 AUDIO
|
||||
TITLE "Crimson, Part 5"
|
||||
REM REPLAYGAIN_TRACK_GAIN -4.90 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.032541
|
||||
INDEX 01 03:09:01
|
||||
TRACK 06 AUDIO
|
||||
TITLE "Crimson, Part 6"
|
||||
REM REPLAYGAIN_TRACK_GAIN -3.74 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.025570
|
||||
INDEX 01 04:02:15
|
||||
TRACK 07 AUDIO
|
||||
TITLE "Crimson, Part 7"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.83 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.069188
|
||||
INDEX 01 04:54:07
|
||||
TRACK 08 AUDIO
|
||||
TITLE "Crimson, Part 8"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.75 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.078141
|
||||
INDEX 01 05:13:71
|
||||
TRACK 09 AUDIO
|
||||
TITLE "Crimson, Part 9"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.00 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.081721
|
||||
INDEX 01 05:44:56
|
||||
TRACK 10 AUDIO
|
||||
TITLE "Crimson, Part 10"
|
||||
REM REPLAYGAIN_TRACK_GAIN -11.05 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.061313
|
||||
INDEX 01 06:08:50
|
||||
TRACK 11 AUDIO
|
||||
TITLE "Crimson, Part 11"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.83 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.058353
|
||||
INDEX 01 06:40:64
|
||||
TRACK 12 AUDIO
|
||||
TITLE "Crimson, Part 12"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.66 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.049014
|
||||
INDEX 01 07:46:36
|
||||
TRACK 13 AUDIO
|
||||
TITLE "Crimson, Part 13"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.96 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.080913
|
||||
INDEX 01 08:19:59
|
||||
TRACK 14 AUDIO
|
||||
TITLE "Crimson, Part 14"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.41 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.063654
|
||||
INDEX 01 09:17:06
|
||||
TRACK 15 AUDIO
|
||||
TITLE "Crimson, Part 15"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.16 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.050239
|
||||
INDEX 01 10:45:65
|
||||
TRACK 16 AUDIO
|
||||
TITLE "Crimson, Part 16"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.77 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.103854
|
||||
INDEX 01 11:06:35
|
||||
TRACK 17 AUDIO
|
||||
TITLE "Crimson, Part 17"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.96 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.099316
|
||||
INDEX 01 11:47:15
|
||||
TRACK 18 AUDIO
|
||||
TITLE "Crimson, Part 18"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.34 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.093543
|
||||
INDEX 01 12:11:52
|
||||
TRACK 19 AUDIO
|
||||
TITLE "Crimson, Part 19"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.63 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.090411
|
||||
INDEX 01 12:41:62
|
||||
TRACK 20 AUDIO
|
||||
TITLE "Crimson, Part 20"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.76 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.073009
|
||||
INDEX 01 12:59:27
|
||||
TRACK 21 AUDIO
|
||||
TITLE "Crimson, Part 21"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.51 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.075462
|
||||
INDEX 01 14:04:09
|
||||
TRACK 22 AUDIO
|
||||
TITLE "Crimson, Part 22"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.60 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.081226
|
||||
INDEX 01 14:44:25
|
||||
TRACK 23 AUDIO
|
||||
TITLE "Crimson, Part 23"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.60 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.102361
|
||||
INDEX 01 15:33:20
|
||||
TRACK 24 AUDIO
|
||||
TITLE "Crimson, Part 24"
|
||||
REM REPLAYGAIN_TRACK_GAIN -7.61 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.091328
|
||||
INDEX 01 16:31:58
|
||||
TRACK 25 AUDIO
|
||||
TITLE "Crimson, Part 25"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.62 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.080595
|
||||
INDEX 01 17:45:49
|
||||
TRACK 26 AUDIO
|
||||
TITLE "Crimson, Part 26"
|
||||
REM REPLAYGAIN_TRACK_GAIN -8.70 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.097867
|
||||
INDEX 01 18:16:61
|
||||
TRACK 27 AUDIO
|
||||
TITLE "Crimson, Part 27"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.71 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.089200
|
||||
INDEX 01 19:12:50
|
||||
TRACK 28 AUDIO
|
||||
TITLE "Crimson, Part 28"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.87 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.090685
|
||||
INDEX 01 19:59:58
|
||||
TRACK 29 AUDIO
|
||||
TITLE "Crimson, Part 29"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.75 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.124153
|
||||
INDEX 01 20:44:19
|
||||
TRACK 30 AUDIO
|
||||
TITLE "Crimson, Part 30"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.85 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.087260
|
||||
INDEX 01 21:40:05
|
||||
TRACK 31 AUDIO
|
||||
TITLE "Crimson, Part 31"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.44 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.080998
|
||||
INDEX 01 22:52:63
|
||||
TRACK 32 AUDIO
|
||||
TITLE "Crimson, Part 32"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.25 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.084413
|
||||
INDEX 01 24:27:57
|
||||
TRACK 33 AUDIO
|
||||
TITLE "Crimson, Part 33"
|
||||
REM REPLAYGAIN_TRACK_GAIN -11.27 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.089645
|
||||
INDEX 01 25:43:28
|
||||
TRACK 34 AUDIO
|
||||
TITLE "Crimson, Part 34"
|
||||
REM REPLAYGAIN_TRACK_GAIN -6.95 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.073506
|
||||
INDEX 01 26:40:35
|
||||
TRACK 35 AUDIO
|
||||
TITLE "Crimson, Part 35"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.39 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.102312
|
||||
INDEX 01 27:51:54
|
||||
TRACK 36 AUDIO
|
||||
TITLE "Crimson, Part 36"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.35 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.065695
|
||||
INDEX 01 29:14:55
|
||||
TRACK 37 AUDIO
|
||||
TITLE "Crimson, Part 37"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.79 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.074667
|
||||
INDEX 01 30:05:16
|
||||
TRACK 38 AUDIO
|
||||
TITLE "Crimson, Part 38"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.20 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.072641
|
||||
INDEX 01 31:06:70
|
||||
TRACK 39 AUDIO
|
||||
TITLE "Crimson, Part 39"
|
||||
REM REPLAYGAIN_TRACK_GAIN -3.66 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 0.813600
|
||||
INDEX 01 31:39:38
|
||||
TRACK 40 AUDIO
|
||||
TITLE "Crimson, Part 40"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.18 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.095581
|
||||
INDEX 01 32:27:00
|
||||
TRACK 41 AUDIO
|
||||
TITLE "Crimson, Part 41"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.26 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.096531
|
||||
INDEX 01 33:20:11
|
||||
TRACK 42 AUDIO
|
||||
TITLE "Crimson, Part 42"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.22 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.099213
|
||||
INDEX 01 34:18:19
|
||||
TRACK 43 AUDIO
|
||||
TITLE "Crimson, Part 43"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.09 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.091079
|
||||
INDEX 01 35:54:45
|
||||
TRACK 44 AUDIO
|
||||
TITLE "Crimson, Part 44"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.66 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.067710
|
||||
INDEX 01 37:32:16
|
||||
TRACK 45 AUDIO
|
||||
TITLE "Crimson, Part 45"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.82 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.078692
|
||||
INDEX 01 37:52:12
|
||||
TRACK 46 AUDIO
|
||||
TITLE "Crimson, Part 46"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.10 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.061775
|
||||
INDEX 01 38:13:64
|
||||
TRACK 47 AUDIO
|
||||
TITLE "Crimson II, Part 1"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.84 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.053018
|
||||
INDEX 00 38:36:29
|
||||
INDEX 01 38:38:29
|
||||
TRACK 48 AUDIO
|
||||
TITLE "Crimson II, Part 2"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.44 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.069183
|
||||
INDEX 01 39:56:22
|
||||
TRACK 49 AUDIO
|
||||
TITLE "Crimson II, Part 3"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.042545
|
||||
INDEX 01 41:00:31
|
||||
TRACK 50 AUDIO
|
||||
TITLE "Crimson II, Part 4"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.33 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.039090
|
||||
INDEX 01 41:43:16
|
||||
TRACK 51 AUDIO
|
||||
TITLE "Crimson II, Part 5"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.77 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.076504
|
||||
INDEX 01 42:47:19
|
||||
TRACK 52 AUDIO
|
||||
TITLE "Crimson II, Part 6"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.00 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.066017
|
||||
INDEX 01 43:47:08
|
||||
TRACK 53 AUDIO
|
||||
TITLE "Crimson II, Part 7"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.48 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.053724
|
||||
INDEX 01 44:34:55
|
||||
TRACK 54 AUDIO
|
||||
TITLE "Crimson II, Part 8"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.059979
|
||||
INDEX 01 45:47:35
|
||||
TRACK 55 AUDIO
|
||||
TITLE "Crimson II, Part 9"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.24 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.043081
|
||||
INDEX 01 46:52:41
|
||||
TRACK 56 AUDIO
|
||||
TITLE "Crimson II, Part 10"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.53 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.059817
|
||||
INDEX 01 47:32:38
|
||||
TRACK 57 AUDIO
|
||||
TITLE "Crimson II, Part 11"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.35 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.052755
|
||||
INDEX 01 48:39:01
|
||||
TRACK 58 AUDIO
|
||||
TITLE "Crimson II, Part 12"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.17 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.039192
|
||||
INDEX 01 49:27:22
|
||||
TRACK 59 AUDIO
|
||||
TITLE "Crimson II, Part 13"
|
||||
REM REPLAYGAIN_TRACK_GAIN -8.15 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.029701
|
||||
INDEX 01 50:11:11
|
||||
TRACK 60 AUDIO
|
||||
TITLE "Crimson II, Part 14"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.18 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.084152
|
||||
INDEX 01 51:23:51
|
||||
TRACK 61 AUDIO
|
||||
TITLE "Crimson II, Part 15"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.29 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.064918
|
||||
INDEX 01 51:59:19
|
||||
TRACK 62 AUDIO
|
||||
TITLE "Crimson II, Part 16"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.44 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.039924
|
||||
INDEX 01 52:57:70
|
||||
TRACK 63 AUDIO
|
||||
TITLE "Crimson II, Part 17"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.37 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.065348
|
||||
INDEX 01 53:41:13
|
||||
TRACK 64 AUDIO
|
||||
TITLE "Crimson II, Part 18"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.58 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.055689
|
||||
INDEX 01 54:35:73
|
||||
TRACK 65 AUDIO
|
||||
TITLE "Crimson II, Part 19"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.20 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.043689
|
||||
INDEX 01 55:47:60
|
||||
TRACK 66 AUDIO
|
||||
TITLE "Crimson II, Part 20"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.79 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.054136
|
||||
INDEX 01 56:52:64
|
||||
TRACK 67 AUDIO
|
||||
TITLE "Crimson II, Part 21"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.55 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.031386
|
||||
INDEX 01 58:09:48
|
||||
TRACK 68 AUDIO
|
||||
TITLE "Crimson II, Part 22"
|
||||
REM REPLAYGAIN_TRACK_GAIN -2.30 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.018072
|
||||
INDEX 01 58:28:56
|
||||
TRACK 69 AUDIO
|
||||
TITLE "Crimson II, Part 23"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.04 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.056946
|
||||
INDEX 01 59:05:32
|
||||
TRACK 70 AUDIO
|
||||
TITLE "Crimson II, Part 24"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.40 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.050760
|
||||
INDEX 01 59:17:71
|
||||
TRACK 71 AUDIO
|
||||
TITLE "Crimson II, Part 25"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.41 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.039001
|
||||
INDEX 01 59:58:08
|
||||
TRACK 72 AUDIO
|
||||
TITLE "Crimson II, Part 26"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.82 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.057534
|
||||
INDEX 01 60:20:56
|
||||
TRACK 73 AUDIO
|
||||
TITLE "Crimson II, Part 27"
|
||||
REM REPLAYGAIN_TRACK_GAIN +0.03 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 0.535926
|
||||
INDEX 01 61:13:25
|
||||
TRACK 74 AUDIO
|
||||
TITLE "Crimson II, Part 28"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.84 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.053493
|
||||
INDEX 01 61:33:62
|
||||
TRACK 75 AUDIO
|
||||
TITLE "Crimson II, Part 29"
|
||||
REM REPLAYGAIN_TRACK_GAIN -8.94 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.034394
|
||||
INDEX 01 62:22:38
|
||||
TRACK 76 AUDIO
|
||||
TITLE "Crimson II, Part 30"
|
||||
REM REPLAYGAIN_TRACK_GAIN -5.36 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 0.981271
|
||||
INDEX 01 62:49:03
|
||||
TRACK 77 AUDIO
|
||||
TITLE "Crimson II, Part 31"
|
||||
REM REPLAYGAIN_TRACK_GAIN -6.23 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.002305
|
||||
INDEX 01 63:35:61
|
||||
TRACK 78 AUDIO
|
||||
TITLE "Crimson II, Part 32"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.40 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.057299
|
||||
INDEX 01 64:45:09
|
||||
TRACK 79 AUDIO
|
||||
TITLE "Crimson II, Part 33"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.62 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.036386
|
||||
INDEX 01 65:02:34
|
||||
TRACK 80 AUDIO
|
||||
TITLE "Crimson II, Part 34"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.51 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.048394
|
||||
INDEX 01 65:18:29
|
||||
TRACK 81 AUDIO
|
||||
TITLE "Crimson II, Part 35"
|
||||
REM REPLAYGAIN_TRACK_GAIN -7.50 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.013283
|
||||
INDEX 01 66:30:30
|
||||
TRACK 82 AUDIO
|
||||
TITLE "Crimson II, Part 36"
|
||||
REM REPLAYGAIN_TRACK_GAIN -7.71 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.016238
|
||||
INDEX 01 67:14:40
|
||||
TRACK 83 AUDIO
|
||||
TITLE "Crimson II, Part 37"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.34 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.069951
|
||||
INDEX 01 68:00:63
|
||||
TRACK 84 AUDIO
|
||||
TITLE "Crimson II, Part 38"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.41 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.046384
|
||||
INDEX 01 69:05:33
|
||||
TRACK 85 AUDIO
|
||||
TITLE "Crimson II, Part 39"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.37 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.065853
|
||||
INDEX 01 69:29:25
|
||||
TRACK 86 AUDIO
|
||||
TITLE "Crimson II, Part 40"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.60 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.059033
|
||||
INDEX 01 69:53:35
|
||||
TRACK 87 AUDIO
|
||||
TITLE "Crimson II, Part 41"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.07 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.051512
|
||||
INDEX 01 70:17:27
|
||||
TRACK 88 AUDIO
|
||||
TITLE "Crimson II, Part 42"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.27 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.047072
|
||||
INDEX 01 70:39:08
|
||||
TRACK 89 AUDIO
|
||||
TITLE "Crimson II, Part 43"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.34 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.061442
|
||||
INDEX 01 71:11:12
|
||||
TRACK 90 AUDIO
|
||||
TITLE "Crimson II, Part 44"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.073117
|
||||
INDEX 01 71:53:55
|
||||
TRACK 91 AUDIO
|
||||
TITLE "Crimson II, Part 45"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.01 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.053137
|
||||
INDEX 01 72:36:17
|
||||
TRACK 92 AUDIO
|
||||
TITLE "Crimson II, Part 46"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.58 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.091899
|
||||
INDEX 01 73:40:45
|
||||
TRACK 93 AUDIO
|
||||
TITLE "Crimson II, Part 47"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.40 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.069606
|
||||
INDEX 01 74:10:05
|
||||
TRACK 94 AUDIO
|
||||
TITLE "Crimson II, Part 48"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.14 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.041885
|
||||
INDEX 01 75:12:34
|
||||
TRACK 95 AUDIO
|
||||
TITLE "Crimson II, Part 49"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.19 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.150353
|
||||
INDEX 01 75:53:31
|
||||
TRACK 96 AUDIO
|
||||
TITLE "Crimson II, Part 50"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.00 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.036077
|
||||
INDEX 01 77:07:17
|
||||
TRACK 97 AUDIO
|
||||
TITLE "Crimson II, Part 51"
|
||||
REM REPLAYGAIN_TRACK_GAIN -10.74 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.055573
|
||||
INDEX 01 77:40:33
|
||||
TRACK 98 AUDIO
|
||||
TITLE "Crimson II, Part 52"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.63 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.034923
|
||||
INDEX 01 78:58:27
|
||||
TRACK 99 AUDIO
|
||||
TITLE "Crimson II, Part 53"
|
||||
REM REPLAYGAIN_TRACK_GAIN -9.48 dB
|
||||
REM REPLAYGAIN_TRACK_PEAK 1.073862
|
||||
INDEX 01 79:27:13
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user