Mappings between local playlist folders and Kreativ-Tonies are now
expressed purely through the filesystem instead of a YAML config:
<root>/<household_id>/<freier_name> [id=<tonie_id>]/*.mp3
- internal/library: discovers tonie folders from this structure
(Discover/Filter), and scaffolds it automatically from the
TonieCloud account (Scaffold), leaving already-existing folders
(matched by tonie id) untouched
- internal/syncer: decoupled from the removed config package via a
plain Target{TonieID, Folder, Prune} struct
- cmd/toni-sync: new `init --root PATH` command to scaffold the
library; `sync [FILTER] --root PATH` now discovers targets from the
folder tree instead of a config file; removed the `config` command
group entirely
- Unit tests for folder discovery, filtering, and scaffolding
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
224 lines
5.7 KiB
Go
224 lines
5.7 KiB
Go
// 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/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
|
|
}
|
|
|
|
// Target describes what to sync: a local folder and the Kreativ-Tonie it maps to.
|
|
// Mappings come from the on-disk library folder structure (see internal/library),
|
|
// not from a separate config file.
|
|
type Target struct {
|
|
TonieID string
|
|
Folder string
|
|
Prune bool // remove chapters that no longer have a matching local file
|
|
}
|
|
|
|
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, target Target) (*Plan, error) {
|
|
tonie, err := client.GetCreativeTonie(target.TonieID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tracks, err := ListLocalTracks(target.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 target.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, target Target, 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(target.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
|
|
}
|