Support m3u playlist files to define chapter order

The chapter order on the tonie is already fully API-controlled via
SortChaptersOfTonie/PATCH. This adds an explicit way to define that
order via an .m3u/.m3u8 file placed in the tonie folder, instead of
relying on alphabetical filenames:

- internal/syncer/playlist.go: finds a single playlist file in the
  tonie folder, parses its entries (ignoring blanks/#-comments), and
  reorders local tracks accordingly. Tracks not referenced by the
  playlist are appended afterwards so nothing is silently dropped;
  playlist entries without a matching local file are ignored.
  Multiple playlist files in the same folder is an error (ambiguous
  order).
- ListLocalTracks now prefers this playlist-defined order, falling
  back to alphabetical filename order when no playlist is present.
- Tests covering playlist ordering, unreferenced-track handling,
  missing entries, and the multiple-playlists error case.
- README documents the .m3u/.m3u8 convention.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-14 13:26:09 +02:00
co-authored by Copilot
parent ae46e8f5c0
commit eb77c0ecbd
5 changed files with 252 additions and 5 deletions
+104
View File
@@ -0,0 +1,104 @@
package syncer
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
var playlistExtensions = map[string]bool{
".m3u": true,
".m3u8": true,
}
// findPlaylistFile looks for a single .m3u/.m3u8 file directly inside folder.
// Returns "" if none is present. Returns an error if more than one is found,
// since the order would then be ambiguous.
func findPlaylistFile(folder string, entries []os.DirEntry) (string, error) {
var found []string
for _, e := range entries {
if e.IsDir() {
continue
}
if playlistExtensions[strings.ToLower(filepath.Ext(e.Name()))] {
found = append(found, e.Name())
}
}
switch len(found) {
case 0:
return "", nil
case 1:
return filepath.Join(folder, found[0]), nil
default:
return "", fmt.Errorf("multiple playlist files found in %s (%s) - keep only one to define the chapter order", folder, strings.Join(found, ", "))
}
}
// parseM3U reads an m3u/m3u8 playlist and returns the referenced entry paths
// in file order. Blank lines and lines starting with "#" (comments/EXTM3U
// directives) are ignored. Entries are returned exactly as written in the
// file (relative or absolute); resolution against the playlist's folder
// happens in the caller.
func parseM3U(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("reading playlist %s: %w", path, err)
}
defer f.Close()
var entries []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
entries = append(entries, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading playlist %s: %w", path, err)
}
return entries, nil
}
// orderTracksByPlaylist reorders tracks according to the entry order in the
// given m3u file. Tracks not referenced by the playlist are appended
// afterwards in their existing (alphabetical) order, so nothing is silently
// dropped from the sync. Playlist entries that don't match any local track
// are ignored.
func orderTracksByPlaylist(folder, playlistPath string, tracks []Track) ([]Track, error) {
entries, err := parseM3U(playlistPath)
if err != nil {
return nil, err
}
byPath := make(map[string]Track, len(tracks))
for _, t := range tracks {
byPath[t.Path] = t
}
used := make(map[string]bool, len(entries))
ordered := make([]Track, 0, len(tracks))
for _, entry := range entries {
resolved := entry
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(folder, resolved)
}
resolved = filepath.Clean(resolved)
if t, ok := byPath[resolved]; ok {
ordered = append(ordered, t)
used[resolved] = true
}
}
// Append any local tracks not referenced by the playlist, preserving
// their existing order, so they aren't silently excluded from sync.
for _, t := range tracks {
if !used[t.Path] {
ordered = append(ordered, t)
}
}
return ordered, nil
}
+104
View File
@@ -0,0 +1,104 @@
package syncer
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestListLocalTracksUsesM3UOrderWhenPresent(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "b-track.mp3", "data")
writeFile(t, dir, "a-track.mp3", "data")
writeFile(t, dir, "c-track.mp3", "data")
writeFile(t, dir, "playlist.m3u", "#EXTM3U\nb-track.mp3\nc-track.mp3\na-track.mp3\n")
tracks, err := ListLocalTracks(dir)
if err != nil {
t.Fatal(err)
}
got := titles(tracks)
want := []string{"b-track", "c-track", "a-track"}
if !equalStrings(got, want) {
t.Fatalf("expected order %v, got %v", want, got)
}
}
func TestListLocalTracksAppendsUnreferencedTracksAfterPlaylist(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "a-track.mp3", "data")
writeFile(t, dir, "b-track.mp3", "data")
writeFile(t, dir, "z-new-track.mp3", "data")
// Playlist doesn't mention the newly added track yet.
writeFile(t, dir, "playlist.m3u", "b-track.mp3\na-track.mp3\n")
tracks, err := ListLocalTracks(dir)
if err != nil {
t.Fatal(err)
}
got := titles(tracks)
want := []string{"b-track", "a-track", "z-new-track"}
if !equalStrings(got, want) {
t.Fatalf("expected order %v, got %v", want, got)
}
}
func TestListLocalTracksIgnoresMissingPlaylistEntries(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "a-track.mp3", "data")
writeFile(t, dir, "playlist.m3u", "does-not-exist.mp3\na-track.mp3\n")
tracks, err := ListLocalTracks(dir)
if err != nil {
t.Fatal(err)
}
got := titles(tracks)
want := []string{"a-track"}
if !equalStrings(got, want) {
t.Fatalf("expected order %v, got %v", want, got)
}
}
func TestListLocalTracksFallsBackToAlphabeticalWithoutPlaylist(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "b-track.mp3", "data")
writeFile(t, dir, "a-track.mp3", "data")
tracks, err := ListLocalTracks(dir)
if err != nil {
t.Fatal(err)
}
got := titles(tracks)
want := []string{"a-track", "b-track"}
if !equalStrings(got, want) {
t.Fatalf("expected order %v, got %v", want, got)
}
}
func TestListLocalTracksErrorsOnMultiplePlaylists(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "a-track.mp3", "data")
writeFile(t, dir, "one.m3u", "a-track.mp3\n")
writeFile(t, dir, "two.m3u8", "a-track.mp3\n")
if _, err := ListLocalTracks(dir); err == nil {
t.Fatal("expected error when multiple playlist files are present")
}
}
func titles(tracks []Track) []string {
titles := make([]string, len(tracks))
for i, t := range tracks {
titles[i] = t.Title
}
return titles
}
+18 -1
View File
@@ -56,7 +56,12 @@ func TitleFromFilename(name string) string {
return title
}
// ListLocalTracks returns audio files in folder, sorted by filename (defines chapter order).
// ListLocalTracks returns the audio files in folder that should be synced,
// in chapter order.
//
// If the folder contains a single .m3u/.m3u8 playlist file, its entry order
// defines the chapter order (see orderTracksByPlaylist); otherwise tracks
// are sorted alphabetically by filename.
func ListLocalTracks(folder string) ([]Track, error) {
entries, err := os.ReadDir(folder)
if err != nil {
@@ -78,6 +83,18 @@ func ListLocalTracks(folder string) ([]Track, error) {
})
}
sort.Slice(tracks, func(i, j int) bool { return tracks[i].Path < tracks[j].Path })
playlistPath, err := findPlaylistFile(folder, entries)
if err != nil {
return nil, err
}
if playlistPath != "" {
tracks, err = orderTracksByPlaylist(folder, playlistPath, tracks)
if err != nil {
return nil, err
}
}
return tracks, nil
}