// 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/config" "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 } 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 audio files in folder, sorted by filename (defines chapter order). 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 }) 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 } // NeedsChanges reports whether applying the plan would change anything on the tonie. func (p *Plan) NeedsChanges() bool { 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. func BuildPlan(client TonieClient, m config.Mapping) (*Plan, error) { tonie, err := client.GetCreativeTonie(m.TonieID) if err != nil { return nil, err } tracks, err := ListLocalTracks(m.Folder) if err != nil { return nil, err } 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 m.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. func ApplyPlan(client TonieClient, m config.Mapping, plan *Plan) error { 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(m.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 }