Rewrite toni-sync in Go
- Replace the Python implementation with a Go module compiling to a single static binary (no Python/pip runtime dependency) - internal/tonieapi: minimal, dependency-light HTTP client for the TonieCloud REST API (login, households, creative tonies, file upload via presigned S3 request, chapter add/sort/clear) - internal/config: YAML-based mapping of local playlist folders to Kreativ-Tonies, credentials never stored - internal/syncer: diff/apply logic (upload new tracks, prune removed chapters, reorder to match local file order), built against a TonieClient interface for testability - cmd/toni-sync: Cobra CLI with `tonies list`, `config add/list/remove`, `sync [NAME|--all] [--dry-run]` - Unit tests for config persistence and syncer plan/apply logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/arnef/toni-sync/internal/config"
|
||||
"github.com/arnef/toni-sync/internal/tonieapi"
|
||||
)
|
||||
|
||||
// fakeClient is a minimal in-memory stand-in for tonieapi.Client used in tests.
|
||||
type fakeClient struct {
|
||||
tonieSequence []tonieapi.CreativeTonie // consumed in order by GetCreativeTonie calls
|
||||
callIndex int
|
||||
|
||||
uploadedTitles []string
|
||||
sortedTitles []string
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetCreativeTonie(tonieID string) (*tonieapi.CreativeTonie, error) {
|
||||
t := f.tonieSequence[f.callIndex]
|
||||
if f.callIndex < len(f.tonieSequence)-1 {
|
||||
f.callIndex++
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) UploadFileToTonie(tonie tonieapi.CreativeTonie, filePath, title string) error {
|
||||
f.uploadedTitles = append(f.uploadedTitles, title)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) SortChaptersOfTonie(tonie tonieapi.CreativeTonie, chapters []tonieapi.Chapter) error {
|
||||
for _, c := range chapters {
|
||||
f.sortedTitles = append(f.sortedTitles, c.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeTonie(chapters []tonieapi.Chapter) tonieapi.CreativeTonie {
|
||||
return tonieapi.CreativeTonie{ID: "t1", HouseholdID: "h1", Name: "Test Tonie", Chapters: chapters}
|
||||
}
|
||||
|
||||
func setupFolder(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "01 - First.mp3"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "02 - Second.mp3"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestListLocalTracksFiltersByExtension(t *testing.T) {
|
||||
dir := setupFolder(t)
|
||||
tracks, err := ListLocalTracks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tracks) != 2 || tracks[0].Title != "01 - First" || tracks[1].Title != "02 - Second" {
|
||||
t.Fatalf("unexpected tracks: %+v", tracks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLocalTracksMissingFolder(t *testing.T) {
|
||||
if _, err := ListLocalTracks(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected error for missing folder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanDetectsUploadNoRemoval(t *testing.T) {
|
||||
dir := setupFolder(t)
|
||||
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
|
||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
||||
|
||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
||||
plan, err := BuildPlan(client, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(plan.ToUpload) != 1 || plan.ToUpload[0].Title != "02 - Second" {
|
||||
t.Fatalf("expected upload of '02 - Second', got %+v", plan.ToUpload)
|
||||
}
|
||||
if len(plan.ToRemove) != 0 {
|
||||
t.Fatalf("expected no removal, got %+v", plan.ToRemove)
|
||||
}
|
||||
if !plan.NeedsChanges() {
|
||||
t.Fatal("expected NeedsChanges to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanPrunesRemovedChapters(t *testing.T) {
|
||||
dir := setupFolder(t)
|
||||
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
|
||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
||||
|
||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
||||
plan, err := BuildPlan(client, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.ToRemove) != 1 || plan.ToRemove[0].Title != "stale" {
|
||||
t.Fatalf("expected 'stale' to be removed, got %+v", plan.ToRemove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanNoPruneKeepsStaleChapters(t *testing.T) {
|
||||
dir := setupFolder(t)
|
||||
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
|
||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
||||
|
||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: false}
|
||||
plan, err := BuildPlan(client, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.ToRemove) != 0 {
|
||||
t.Fatalf("expected no removal, got %+v", plan.ToRemove)
|
||||
}
|
||||
want := []string{"01 - First", "02 - Second", "stale"}
|
||||
if !equalStrings(plan.FinalOrderTitles, want) {
|
||||
t.Fatalf("expected order %v, got %v", want, plan.FinalOrderTitles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanUploadsAndSkipsReorderWhenAlreadyMatching(t *testing.T) {
|
||||
dir := setupFolder(t)
|
||||
before := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
|
||||
after := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "02 - Second"}})
|
||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{before, after}}
|
||||
|
||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
||||
plan, err := BuildPlan(client, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ApplyPlan(client, m, plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(client.uploadedTitles) != 1 || client.uploadedTitles[0] != "02 - Second" {
|
||||
t.Fatalf("expected upload of '02 - Second', got %v", client.uploadedTitles)
|
||||
}
|
||||
if len(client.sortedTitles) != 0 {
|
||||
t.Fatalf("expected no sort call since order already matches, got %v", client.sortedTitles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanReordersWhenOrderDiffers(t *testing.T) {
|
||||
dir := setupFolder(t)
|
||||
reversed := makeTonie([]tonieapi.Chapter{{ID: "c2", Title: "02 - Second"}, {ID: "c1", Title: "01 - First"}})
|
||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{reversed, reversed}}
|
||||
|
||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
||||
plan, err := BuildPlan(client, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ApplyPlan(client, m, plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(client.uploadedTitles) != 0 {
|
||||
t.Fatalf("expected no uploads, got %v", client.uploadedTitles)
|
||||
}
|
||||
want := []string{"01 - First", "02 - Second"}
|
||||
if !equalStrings(client.sortedTitles, want) {
|
||||
t.Fatalf("expected sorted order %v, got %v", want, client.sortedTitles)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user