feat: write an m3u playlist to preserve playlist track order
Playlist file names carry no ordering information, so the sequence a user curated on Deezer was lost once the tracks landed on disk (unlike albums, which get a zero padded track number prefix). downloadTrack now reports the output path on success as well as on skip, and downloadAllTracks collects the per-track paths for playlist downloads and writes them, in order, to an extended m3u file next to the audio. Failed tracks are left out since there is nothing on disk to point at; a write failure is reported as a warning rather than failing the run.
This commit is contained in:
@@ -134,6 +134,8 @@ func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Reso
|
|||||||
|
|
||||||
progress := newProgressTracker(len(tracks), d.kind)
|
progress := newProgressTracker(len(tracks), d.kind)
|
||||||
|
|
||||||
|
var m3uEntries []m3uEntry
|
||||||
|
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -148,6 +150,19 @@ func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Reso
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress.handleResult(i, track, result)
|
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))
|
progress.printSummary(outputDir, time.Since(startTime))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource
|
|||||||
warnings = append(warnings, metadata.warnings...)
|
warnings = append(warnings, metadata.warnings...)
|
||||||
warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, outputFormat, metadata.genre, cover, metadata.bpmKey)...)
|
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)",
|
// uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",
|
||||||
|
|||||||
Reference in New Issue
Block a user