Files
arnefandCopilot 4e1a89cb05 Skip empty tonie folders instead of pruning all chapters
Previously, an accidentally empty tonie folder combined with pruning
(the default) would delete every existing chapter on the tonie. Now:

- syncer.BuildPlan detects an empty local folder and returns a
  Plan{Skipped: true, SkipReason: ...} instead of computing a diff
- syncer.ApplyPlan is a no-op for a skipped plan (defense in depth)
- Plan.NeedsChanges() reports false for skipped plans
- cmd/toni-sync sync prints "Skipped: <reason>" for such folders and
  moves on, regardless of --no-prune
- Added a test covering the skip behavior end-to-end (BuildPlan +
  ApplyPlan no-op)
- README documents the safety behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-14 19:27:58 +02:00

268 lines
6.9 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
// Skipped is true when the plan intentionally does nothing, e.g. because
// the local folder is empty. SkipReason explains why.
Skipped bool
SkipReason string
}
// NeedsChanges reports whether applying the plan would change anything on the tonie.
func (p *Plan) NeedsChanges() bool {
if p.Skipped {
return false
}
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.
//
// If the local folder contains no audio tracks, the plan is marked as
// Skipped instead of computing a diff - otherwise an accidentally empty
// folder (e.g. not yet filled, or a transient sync issue) would delete all
// chapters on the tonie when pruning is enabled.
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
}
if len(tracks) == 0 {
return &Plan{
Tonie: *tonie,
LocalTracks: tracks,
Skipped: true,
SkipReason: "folder is empty - skipping to avoid deleting all chapters on the tonie",
}, nil
}
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.
// It is a no-op if the plan is Skipped (see BuildPlan).
func ApplyPlan(client TonieClient, target Target, plan *Plan) error {
if plan.Skipped {
return nil
}
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
}