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
|
existieren (abschaltbar via `--no-prune`), und sortiert die Kapitel passend
|
||||||
zur lokalen Reihenfolge (Playlist-Datei falls vorhanden, sonst alphabetisch).
|
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
|
### Household-/Tonie-IDs nachschlagen
|
||||||
|
|
||||||
Falls du die Struktur lieber manuell pflegen willst:
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if plan.Skipped {
|
||||||
|
fmt.Printf(" Skipped: %s\n", plan.SkipReason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(plan.ToUpload) > 0 {
|
if len(plan.ToUpload) > 0 {
|
||||||
fmt.Printf(" Upload (%d):\n", len(plan.ToUpload))
|
fmt.Printf(" Upload (%d):\n", len(plan.ToUpload))
|
||||||
for _, track := range plan.ToUpload {
|
for _, track := range plan.ToUpload {
|
||||||
|
|||||||
@@ -105,10 +105,18 @@ type Plan struct {
|
|||||||
ToUpload []Track
|
ToUpload []Track
|
||||||
ToRemove []tonieapi.Chapter
|
ToRemove []tonieapi.Chapter
|
||||||
FinalOrderTitles []string
|
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.
|
// NeedsChanges reports whether applying the plan would change anything on the tonie.
|
||||||
func (p *Plan) NeedsChanges() bool {
|
func (p *Plan) NeedsChanges() bool {
|
||||||
|
if p.Skipped {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if len(p.ToUpload) > 0 || len(p.ToRemove) > 0 {
|
if len(p.ToUpload) > 0 || len(p.ToRemove) > 0 {
|
||||||
return true
|
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.
|
// 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) {
|
func BuildPlan(client TonieClient, target Target) (*Plan, error) {
|
||||||
tonie, err := client.GetCreativeTonie(target.TonieID)
|
tonie, err := client.GetCreativeTonie(target.TonieID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,6 +159,15 @@ func BuildPlan(client TonieClient, target Target) (*Plan, error) {
|
|||||||
return nil, err
|
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))
|
localTitles := make(map[string]bool, len(tracks))
|
||||||
orderedTitles := make([]string, 0, len(tracks))
|
orderedTitles := make([]string, 0, len(tracks))
|
||||||
for _, t := range 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.
|
// 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 {
|
func ApplyPlan(client TonieClient, target Target, plan *Plan) error {
|
||||||
|
if plan.Skipped {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, track := range plan.ToUpload {
|
for _, track := range plan.ToUpload {
|
||||||
if err := client.UploadFileToTonie(plan.Tonie, track.Path, track.Title); err != nil {
|
if err := client.UploadFileToTonie(plan.Tonie, track.Path, track.Title); err != nil {
|
||||||
return fmt.Errorf("uploading %s: %w", track.Path, err)
|
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) {
|
func TestApplyPlanUploadsAndSkipsReorderWhenAlreadyMatching(t *testing.T) {
|
||||||
dir := setupFolder(t)
|
dir := setupFolder(t)
|
||||||
before := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
|
before := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
|
||||||
|
|||||||
Reference in New Issue
Block a user