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>
105 lines
2.9 KiB
Go
105 lines
2.9 KiB
Go
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
|
|
}
|