diff --git a/internal/download/download.go b/internal/download/download.go index 37be963..f93f247 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -134,6 +134,8 @@ func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Reso progress := newProgressTracker(len(tracks), d.kind) + var m3uEntries []m3uEntry + for i, track := range tracks { if ctx.Err() != nil { return ctx.Err() @@ -148,6 +150,19 @@ func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Reso } progress.handleResult(i, track, result) + + if d.kind == deezer.KindPlaylist && result.err == nil { + m3uEntries = append(m3uEntries, m3uEntry{track: track, path: result.path}) + } + } + + // Written after the loop, not per track, so a run that fails partway + // through still produces a playlist covering whatever did make it to + // disk instead of no playlist at all. + if d.kind == deezer.KindPlaylist { + if err := writeM3U(outputDir, m3uEntries); err != nil { + fmt.Printf("\nWarning: failed to write m3u playlist: %v\n", err) + } } progress.printSummary(outputDir, time.Since(startTime)) diff --git a/internal/download/m3u.go b/internal/download/m3u.go new file mode 100644 index 0000000..d59bc1c --- /dev/null +++ b/internal/download/m3u.go @@ -0,0 +1,60 @@ +package download + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/mathismqn/godeez/internal/deezer" +) + +// m3uEntry pairs a playlist track with the file it ended up at, so the m3u +// can be written after the fact without re-deriving the path. +type m3uEntry struct { + track *deezer.Track + path string +} + +// writeM3U records a playlist's tracks in their original order as an +// extended M3U file next to the downloaded audio. +// +// This exists because the on-disk file names carry no ordering for +// playlists (unlike albums, which get a zero padded track number prefix): +// without an m3u, the sequence the user curated on Deezer is lost the moment +// the tracks land in a directory that any player will list alphabetically. +// +// Skipped tracks are included: they are already on disk from a previous run +// and belong at their playlist position just as much as one downloaded this +// run. Failed tracks are left out since there is nothing on disk to point +// at, and a broken entry would only confuse the player. +// +// Paths are written relative to outputDir so the m3u keeps working if the +// whole playlist folder is moved or copied elsewhere. +func writeM3U(outputDir string, entries []m3uEntry) error { + if len(entries) == 0 { + return nil + } + + m3uPath := filepath.Join(outputDir, filepath.Base(outputDir)+".m3u") + + var b strings.Builder + b.WriteString("#EXTM3U\n") + + for _, e := range entries { + duration, err := strconv.Atoi(e.track.Duration) + if err != nil { + duration = 0 + } + + rel, err := filepath.Rel(outputDir, e.path) + if err != nil { + rel = e.path + } + + fmt.Fprintf(&b, "#EXTINF:%d,%s - %s\n%s\n", duration, e.track.Artist, e.track.FullTitle(), rel) + } + + return os.WriteFile(m3uPath, []byte(b.String()), 0644) +} diff --git a/internal/download/track.go b/internal/download/track.go index 7d37e1b..fa2c1a0 100644 --- a/internal/download/track.go +++ b/internal/download/track.go @@ -107,7 +107,7 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource warnings = append(warnings, metadata.warnings...) warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, outputFormat, metadata.genre, cover, metadata.bpmKey)...) - return downloadResult{warnings: warnings} + return downloadResult{path: outputPath, warnings: warnings} } // uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",