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
+23 -3
View File
@@ -94,8 +94,28 @@ Der Library-Root wird wie folgt bestimmt (erste zutreffende Option):
### 3. Audiodateien einsortieren & synchronisieren
Lege deine (legal exportierten) Audiodateien in den passenden Tonie-Ordner,
z. B. `01 - Track.mp3`, `02 - Track.mp3`, ...
Lege deine (legal exportierten) Audiodateien in den passenden Tonie-Ordner.
Die Kapitelreihenfolge wird wie folgt bestimmt:
- **Mit Playlist:** Liegt genau eine `.m3u`/`.m3u8`-Datei im Tonie-Ordner,
bestimmt deren Zeilenreihenfolge die Kapitelreihenfolge (Kommentare/`#`-Zeilen
und Leerzeilen werden ignoriert, Pfade relativ zum Ordner). Dateien, die
lokal existieren aber nicht in der Playlist stehen, werden trotzdem
synchronisiert und ans Ende angehängt (nichts geht verloren). Einträge in
der Playlist ohne passende Datei werden ignoriert. Liegen mehrere Playlist-
Dateien im selben Ordner, bricht `sync` mit einem Fehler ab (Reihenfolge
wäre sonst mehrdeutig).
- **Ohne Playlist:** alphabetische Dateireihenfolge, z. B. `01 - Track.mp3`,
`02 - Track.mp3`, ...
Beispiel `playlist.m3u`:
```
#EXTM3U
02 - Second Track.mp3
01 - First Track.mp3
03 - Third Track.mp3
```
```bash
# alle gefundenen Tonies synchronisieren
@@ -110,7 +130,7 @@ toni-sync sync --root ~/Musik/tonies --dry-run
`sync` lädt neue Dateien hoch, entfernt Kapitel, die lokal nicht mehr
existieren (abschaltbar via `--no-prune`), und sortiert die Kapitel passend
zur lokalen Dateireihenfolge.
zur lokalen Reihenfolge (Playlist-Datei falls vorhanden, sonst alphabetisch).
### Household-/Tonie-IDs nachschlagen
+3 -1
View File
@@ -20,7 +20,9 @@ func newSyncCmd() *cobra.Command {
Long: "Discovers all '<root>/<household_id>/<freier_name> [id=<tonie_id>]'\n" +
"folders and syncs each one to its Kreativ-Tonie: uploads new files,\n" +
"removes chapters no longer present locally (unless --no-prune is set),\n" +
"and reorders chapters to match the local folder's file order.\n\n" +
"and reorders chapters to match the local order (an .m3u/.m3u8\n" +
"playlist file in the tonie folder if present, otherwise alphabetical\n" +
"filename order).\n\n" +
"FILTER optionally restricts syncing to tonies whose household id, tonie\n" +
"id, or name contains the given text (case-insensitive). Without a\n" +
"filter, all discovered tonies are synced.",
+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
}