diff --git a/README.md b/README.md index 04356e9..1bf976c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/toni-sync/sync.go b/cmd/toni-sync/sync.go index 8b75dcc..898c01c 100644 --- a/cmd/toni-sync/sync.go +++ b/cmd/toni-sync/sync.go @@ -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 { diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index e1769e8..a6836dd 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -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) diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index 6f975e0..8495984 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -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"}})