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>
This commit is contained in:
2026-08-14 19:27:58 +02:00
co-authored by Copilot
parent eb77c0ecbd
commit 4e1a89cb05
4 changed files with 62 additions and 0 deletions
+4
View File
@@ -132,6 +132,10 @@ toni-sync sync --root ~/Musik/tonies --dry-run
existieren (abschaltbar via `--no-prune`), und sortiert die Kapitel passend
zur lokalen Reihenfolge (Playlist-Datei falls vorhanden, sonst alphabetisch).
**Leere Tonie-Ordner werden übersprungen** - so verhindert toni-sync, dass
ein versehentlich leerer Ordner (z. B. noch nicht befüllt) beim Sync alle
vorhandenen Kapitel auf dem Tonie löscht.
### Household-/Tonie-IDs nachschlagen
Falls du die Struktur lieber manuell pflegen willst:
+5
View File
@@ -77,6 +77,11 @@ func syncOne(client *tonieapi.Client, t library.Tonie, prune, dryRun bool) error
return err
}
if plan.Skipped {
fmt.Printf(" Skipped: %s\n", plan.SkipReason)
return nil
}
if len(plan.ToUpload) > 0 {
fmt.Printf(" Upload (%d):\n", len(plan.ToUpload))
for _, track := range plan.ToUpload {
+27
View File
@@ -105,10 +105,18 @@ type Plan struct {
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
}
@@ -135,6 +143,11 @@ func (p *Plan) NeedsChanges() bool {
}
// 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 {
@@ -146,6 +159,15 @@ func BuildPlan(client TonieClient, target Target) (*Plan, error) {
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 {
@@ -191,7 +213,12 @@ func BuildPlan(client TonieClient, target Target) (*Plan, error) {
}
// 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)
+26
View File
@@ -129,6 +129,32 @@ func TestBuildPlanNoPruneKeepsStaleChapters(t *testing.T) {
}
}
func TestBuildPlanSkipsEmptyFolder(t *testing.T) {
dir := t.TempDir() // no audio files
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "existing chapter"}})
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
m := Target{TonieID: "t1", Folder: dir, Prune: true}
plan, err := BuildPlan(client, m)
if err != nil {
t.Fatal(err)
}
if !plan.Skipped {
t.Fatal("expected plan to be skipped for empty folder")
}
if plan.NeedsChanges() {
t.Fatal("expected NeedsChanges to be false for a skipped plan")
}
if err := ApplyPlan(client, m, plan); err != nil {
t.Fatal(err)
}
if len(client.sortedTitles) != 0 || len(client.uploadedTitles) != 0 {
t.Fatalf("expected ApplyPlan to be a no-op for a skipped plan, got uploads=%v sorted=%v", client.uploadedTitles, client.sortedTitles)
}
}
func TestApplyPlanUploadsAndSkipsReorderWhenAlreadyMatching(t *testing.T) {
dir := setupFolder(t)
before := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})