diff --git a/README.md b/README.md index 94af214..04356e9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/toni-sync/sync.go b/cmd/toni-sync/sync.go index 6f73f58..8b75dcc 100644 --- a/cmd/toni-sync/sync.go +++ b/cmd/toni-sync/sync.go @@ -20,7 +20,9 @@ func newSyncCmd() *cobra.Command { Long: "Discovers all '// [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.", diff --git a/internal/syncer/playlist.go b/internal/syncer/playlist.go new file mode 100644 index 0000000..923c7b5 --- /dev/null +++ b/internal/syncer/playlist.go @@ -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 +} diff --git a/internal/syncer/playlist_test.go b/internal/syncer/playlist_test.go new file mode 100644 index 0000000..793c5c0 --- /dev/null +++ b/internal/syncer/playlist_test.go @@ -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 +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index b046ebe..e1769e8 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -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 }