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:
@@ -94,8 +94,28 @@ Der Library-Root wird wie folgt bestimmt (erste zutreffende Option):
|
|||||||
|
|
||||||
### 3. Audiodateien einsortieren & synchronisieren
|
### 3. Audiodateien einsortieren & synchronisieren
|
||||||
|
|
||||||
Lege deine (legal exportierten) Audiodateien in den passenden Tonie-Ordner,
|
Lege deine (legal exportierten) Audiodateien in den passenden Tonie-Ordner.
|
||||||
z. B. `01 - Track.mp3`, `02 - Track.mp3`, ...
|
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
|
```bash
|
||||||
# alle gefundenen Tonies synchronisieren
|
# 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
|
`sync` lädt neue Dateien hoch, entfernt Kapitel, die lokal nicht mehr
|
||||||
existieren (abschaltbar via `--no-prune`), und sortiert die Kapitel passend
|
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
|
### Household-/Tonie-IDs nachschlagen
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ func newSyncCmd() *cobra.Command {
|
|||||||
Long: "Discovers all '<root>/<household_id>/<freier_name> [id=<tonie_id>]'\n" +
|
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" +
|
"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" +
|
"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" +
|
"FILTER optionally restricts syncing to tonies whose household id, tonie\n" +
|
||||||
"id, or name contains the given text (case-insensitive). Without a\n" +
|
"id, or name contains the given text (case-insensitive). Without a\n" +
|
||||||
"filter, all discovered tonies are synced.",
|
"filter, all discovered tonies are synced.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -56,7 +56,12 @@ func TitleFromFilename(name string) string {
|
|||||||
return title
|
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) {
|
func ListLocalTracks(folder string) ([]Track, error) {
|
||||||
entries, err := os.ReadDir(folder)
|
entries, err := os.ReadDir(folder)
|
||||||
if err != nil {
|
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 })
|
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
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user