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:
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
Reference in New Issue
Block a user