Files
toni-sync/internal/syncer/syncer.go
T
arnefandCopilot eb77c0ecbd 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>
2026-08-14 13:26:09 +02:00

241 lines
6.1 KiB
Go

// Package syncer contains the diff/apply logic that reconciles a local
// audio folder with the chapters of a Kreativ-Tonie.
package syncer
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/arnef/toni-sync/internal/tonieapi"
)
// TonieClient is the subset of tonieapi.Client used by the syncer, extracted
// as an interface so it can be faked in tests.
type TonieClient interface {
GetCreativeTonie(tonieID string) (*tonieapi.CreativeTonie, error)
UploadFileToTonie(tonie tonieapi.CreativeTonie, filePath, title string) error
SortChaptersOfTonie(tonie tonieapi.CreativeTonie, chapters []tonieapi.Chapter) error
}
// Target describes what to sync: a local folder and the Kreativ-Tonie it maps to.
// Mappings come from the on-disk library folder structure (see internal/library),
// not from a separate config file.
type Target struct {
TonieID string
Folder string
Prune bool // remove chapters that no longer have a matching local file
}
const maxTitleLength = 100
var audioExtensions = map[string]bool{
".mp3": true,
".m4a": true,
".aac": true,
".ogg": true,
".flac": true,
".wav": true,
}
// Track is a local audio file considered for syncing.
type Track struct {
Path string
Title string
}
// TitleFromFilename derives a chapter title from a filename (its stem, length-capped).
func TitleFromFilename(name string) string {
title := strings.TrimSuffix(name, filepath.Ext(name))
title = strings.TrimSpace(title)
if len(title) > maxTitleLength {
title = title[:maxTitleLength]
}
return title
}
// 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 {
return nil, fmt.Errorf("folder does not exist: %s: %w", folder, err)
}
var tracks []Track
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if !audioExtensions[ext] {
continue
}
tracks = append(tracks, Track{
Path: filepath.Join(folder, e.Name()),
Title: TitleFromFilename(e.Name()),
})
}
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
}
// Plan describes the changes needed to bring a Kreativ-Tonie in sync with a local folder.
type Plan struct {
Tonie tonieapi.CreativeTonie
LocalTracks []Track
ToUpload []Track
ToRemove []tonieapi.Chapter
FinalOrderTitles []string
}
// NeedsChanges reports whether applying the plan would change anything on the tonie.
func (p *Plan) NeedsChanges() bool {
if len(p.ToUpload) > 0 || len(p.ToRemove) > 0 {
return true
}
removed := make(map[string]bool, len(p.ToRemove))
for _, c := range p.ToRemove {
removed[c.Title] = true
}
var kept []string
for _, c := range p.Tonie.Chapters {
if !removed[c.Title] {
kept = append(kept, c.Title)
}
}
limit := len(kept)
if limit > len(p.FinalOrderTitles) {
limit = len(p.FinalOrderTitles)
}
for i := 0; i < limit; i++ {
if kept[i] != p.FinalOrderTitles[i] {
return true
}
}
return len(kept) != len(p.FinalOrderTitles)
}
// BuildPlan fetches the current state of the tonie and computes the diff against the local folder.
func BuildPlan(client TonieClient, target Target) (*Plan, error) {
tonie, err := client.GetCreativeTonie(target.TonieID)
if err != nil {
return nil, err
}
tracks, err := ListLocalTracks(target.Folder)
if err != nil {
return nil, err
}
localTitles := make(map[string]bool, len(tracks))
orderedTitles := make([]string, 0, len(tracks))
for _, t := range tracks {
localTitles[t.Title] = true
orderedTitles = append(orderedTitles, t.Title)
}
existingTitles := make(map[string]bool, len(tonie.Chapters))
for _, c := range tonie.Chapters {
existingTitles[c.Title] = true
}
var toUpload []Track
for _, t := range tracks {
if !existingTitles[t.Title] {
toUpload = append(toUpload, t)
}
}
var toRemove []tonieapi.Chapter
finalOrder := append([]string{}, orderedTitles...)
if target.Prune {
for _, c := range tonie.Chapters {
if !localTitles[c.Title] {
toRemove = append(toRemove, c)
}
}
} else {
for _, c := range tonie.Chapters {
if !localTitles[c.Title] {
finalOrder = append(finalOrder, c.Title)
}
}
}
return &Plan{
Tonie: *tonie,
LocalTracks: tracks,
ToUpload: toUpload,
ToRemove: toRemove,
FinalOrderTitles: finalOrder,
}, nil
}
// ApplyPlan uploads missing tracks, then reorders/prunes chapters to match the local folder.
func ApplyPlan(client TonieClient, target Target, plan *Plan) error {
for _, track := range plan.ToUpload {
if err := client.UploadFileToTonie(plan.Tonie, track.Path, track.Title); err != nil {
return fmt.Errorf("uploading %s: %w", track.Path, err)
}
}
refreshed, err := client.GetCreativeTonie(target.TonieID)
if err != nil {
return fmt.Errorf("refetching tonie after upload: %w", err)
}
chaptersByTitle := make(map[string]tonieapi.Chapter, len(refreshed.Chapters))
currentOrder := make([]string, 0, len(refreshed.Chapters))
for _, c := range refreshed.Chapters {
chaptersByTitle[c.Title] = c
currentOrder = append(currentOrder, c.Title)
}
var ordered []tonieapi.Chapter
var desiredOrder []string
for _, title := range plan.FinalOrderTitles {
if c, ok := chaptersByTitle[title]; ok {
ordered = append(ordered, c)
desiredOrder = append(desiredOrder, title)
}
}
if !equalStrings(currentOrder, desiredOrder) {
if err := client.SortChaptersOfTonie(*refreshed, ordered); err != nil {
return fmt.Errorf("reordering chapters: %w", err)
}
}
return nil
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}