Replace config file with folder-based mapping
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>
This commit is contained in:
@@ -9,13 +9,33 @@ Abhängigkeiten (kein Python/pip nötig).
|
|||||||
> **Hinweis:** toni-sync lädt selbst keine Musik von Deezer herunter. Das
|
> **Hinweis:** toni-sync lädt selbst keine Musik von Deezer herunter. Das
|
||||||
> Herunterladen/Umgehen von DRM-geschützten Streams verstößt gegen die
|
> Herunterladen/Umgehen von DRM-geschützten Streams verstößt gegen die
|
||||||
> Nutzungsbedingungen von Deezer und ggf. gegen Urheberrecht. Lege deine
|
> Nutzungsbedingungen von Deezer und ggf. gegen Urheberrecht. Lege deine
|
||||||
> bereits legal exportierten Audiodateien einfach in die konfigurierten
|
> bereits legal exportierten Audiodateien einfach in die passenden
|
||||||
> lokalen Ordner - toni-sync kümmert sich nur um den Abgleich mit der
|
> lokalen Ordner - toni-sync kümmert sich nur um den Abgleich mit der
|
||||||
> TonieCloud.
|
> TonieCloud.
|
||||||
|
|
||||||
|
## Keine Config-Datei - die Ordnerstruktur *ist* die Konfiguration
|
||||||
|
|
||||||
|
Statt einer separaten Config-Datei wird das Mapping Playlist ↔ Kreativ-Tonie
|
||||||
|
direkt über die Ordnerstruktur abgebildet:
|
||||||
|
|
||||||
|
```
|
||||||
|
<root>/
|
||||||
|
<household_id>/
|
||||||
|
<freier_name> [id=<tonie_id>]/
|
||||||
|
01 - Track.mp3
|
||||||
|
02 - Track.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<household_id>`: die Household-ID deines TonieCloud-Accounts
|
||||||
|
- `<freier_name> [id=<tonie_id>]`: frei wählbarer Name + Tonie-ID in eckigen
|
||||||
|
Klammern - der Name dient nur der Lesbarkeit, für das Mapping zählt allein
|
||||||
|
die ID
|
||||||
|
- Die alphabetische Dateireihenfolge innerhalb eines Tonie-Ordners bestimmt
|
||||||
|
die Kapitelreihenfolge auf dem Tonie
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Benötigt wird nur ein Go-Toolchain (>= 1.21) zum Bauen - danach ist das
|
Benötigt wird nur eine Go-Toolchain (>= 1.21) zum Bauen - danach ist das
|
||||||
Ergebnis eine einzelne Binary ohne weitere Abhängigkeiten:
|
Ergebnis eine einzelne Binary ohne weitere Abhängigkeiten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -40,66 +60,72 @@ export TONI_SYNC_USERNAME="you@example.com"
|
|||||||
export TONI_SYNC_PASSWORD="********"
|
export TONI_SYNC_PASSWORD="********"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ: `--username`/`--password` Flags bei `tonies list` und `sync`.
|
Alternativ: `--username`/`--password` Flags bei `tonies list`, `init` und `sync`.
|
||||||
|
|
||||||
## Nutzung
|
## Nutzung
|
||||||
|
|
||||||
### 1. Household- und Tonie-IDs herausfinden
|
### 1. Library-Ordner automatisch anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toni-sync init --root ~/Musik/tonies
|
||||||
|
```
|
||||||
|
|
||||||
|
Legt für jede Household und jeden Kreativ-Tonie deines Accounts den
|
||||||
|
passenden Ordner an, z. B.:
|
||||||
|
|
||||||
|
```
|
||||||
|
[created] /home/you/Musik/tonies/abcd-1234/Peppa Wutz [id=ef01-5678]
|
||||||
|
[created] /home/you/Musik/tonies/abcd-1234/Gute-Nacht-Geschichten [id=9876-4321]
|
||||||
|
Library ready at /home/you/Musik/tonies
|
||||||
|
```
|
||||||
|
|
||||||
|
Bereits vorhandene Ordner (anhand der Tonie-ID erkannt, auch bei geändertem
|
||||||
|
Namen) werden nicht angefasst - deine Audiodateien bleiben unberührt.
|
||||||
|
|
||||||
|
Alternativ manuell: einfach die Ordner nach obigem Schema selbst anlegen.
|
||||||
|
|
||||||
|
### 2. Root-Ordner festlegen
|
||||||
|
|
||||||
|
Der Library-Root wird wie folgt bestimmt (erste zutreffende Option):
|
||||||
|
|
||||||
|
1. `--root PATH` Flag
|
||||||
|
2. Umgebungsvariable `TONI_SYNC_ROOT`
|
||||||
|
3. aktuelles Arbeitsverzeichnis
|
||||||
|
|
||||||
|
### 3. Audiodateien einsortieren & synchronisieren
|
||||||
|
|
||||||
|
Lege deine (legal exportierten) Audiodateien in den passenden Tonie-Ordner,
|
||||||
|
z. B. `01 - Track.mp3`, `02 - Track.mp3`, ...
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# alle gefundenen Tonies synchronisieren
|
||||||
|
toni-sync sync --root ~/Musik/tonies
|
||||||
|
|
||||||
|
# nur Tonies syncen, deren Household-ID/Tonie-ID/Name den Filter enthalten
|
||||||
|
toni-sync sync peppa --root ~/Musik/tonies
|
||||||
|
|
||||||
|
# nur anzeigen, was sich ändern würde
|
||||||
|
toni-sync sync --root ~/Musik/tonies --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`sync` lädt neue Dateien hoch, entfernt Kapitel, die lokal nicht mehr
|
||||||
|
existieren (abschaltbar via `--no-prune`), und sortiert die Kapitel passend
|
||||||
|
zur lokalen Dateireihenfolge.
|
||||||
|
|
||||||
|
### Household-/Tonie-IDs nachschlagen
|
||||||
|
|
||||||
|
Falls du die Struktur lieber manuell pflegen willst:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
toni-sync tonies list
|
toni-sync tonies list
|
||||||
```
|
```
|
||||||
|
|
||||||
Beispielausgabe:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Household: Familie Müller [id=abcd-1234]
|
Household: Familie Müller [id=abcd-1234]
|
||||||
- Peppa Wutz [id=ef01-5678] (12 chapters, 3600s)
|
- Peppa Wutz [id=ef01-5678] (12 chapters, 3600s)
|
||||||
- Gute-Nacht-Geschichten [id=9876-4321] (0 chapters, 0s)
|
- Gute-Nacht-Geschichten [id=9876-4321] (0 chapters, 0s)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Playlist <-> Toni Mapping konfigurieren
|
|
||||||
|
|
||||||
```bash
|
|
||||||
toni-sync config add \
|
|
||||||
--name peppa-wutz \
|
|
||||||
--household-id abcd-1234 \
|
|
||||||
--tonie-id ef01-5678 \
|
|
||||||
--folder ~/Musik/deezer-export/peppa-wutz \
|
|
||||||
--playlist-ref "https://www.deezer.com/playlist/XXXXXXXXX"
|
|
||||||
```
|
|
||||||
|
|
||||||
Weitere Kommandos:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
toni-sync config list
|
|
||||||
toni-sync config remove peppa-wutz
|
|
||||||
```
|
|
||||||
|
|
||||||
Konfiguration wird standardmäßig in `~/.config/toni-sync/config.yaml`
|
|
||||||
gespeichert (überschreibbar via `--config-path` oder `TONI_SYNC_CONFIG`).
|
|
||||||
|
|
||||||
### 3. Synchronisieren
|
|
||||||
|
|
||||||
Lege deine (legal exportierten) Audiodateien in den konfigurierten Ordner,
|
|
||||||
z. B. `01 - Track.mp3`, `02 - Track.mp3`, ... - die alphabetische
|
|
||||||
Dateireihenfolge bestimmt die Kapitelreihenfolge auf dem Tonie.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# einzelnes Mapping
|
|
||||||
toni-sync sync peppa-wutz
|
|
||||||
|
|
||||||
# alle Mappings
|
|
||||||
toni-sync sync --all
|
|
||||||
|
|
||||||
# nur anzeigen, was sich ändern würde
|
|
||||||
toni-sync sync --all --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
`sync` lädt neue Dateien hoch, entfernt Kapitel, die lokal nicht mehr
|
|
||||||
existieren (abschaltbar via `--no-prune` bei `config add`), und sortiert
|
|
||||||
die Kapitel passend zur lokalen Dateireihenfolge.
|
|
||||||
|
|
||||||
## Unterstützte Audioformate
|
## Unterstützte Audioformate
|
||||||
|
|
||||||
`.mp3`, `.m4a`, `.aac`, `.ogg`, `.flac`, `.wav`
|
`.mp3`, `.m4a`, `.aac`, `.ogg`, `.flac`, `.wav`
|
||||||
@@ -107,10 +133,10 @@ die Kapitel passend zur lokalen Dateireihenfolge.
|
|||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/toni-sync/ CLI-Einstiegspunkt (Cobra-Kommandos)
|
cmd/toni-sync/ CLI-Einstiegspunkt (Cobra-Kommandos: tonies, init, sync)
|
||||||
internal/tonieapi/ Eigener, minimaler TonieCloud-API-Client
|
internal/tonieapi/ Eigener, minimaler TonieCloud-API-Client
|
||||||
internal/config/ Laden/Speichern der Playlist<->Toni-Mappings (YAML)
|
internal/library/ Erkennung & Scaffolding der Ordnerstruktur (Household/Tonie)
|
||||||
internal/syncer/ Diff-/Apply-Logik zwischen lokalem Ordner und Tonie
|
internal/syncer/ Diff-/Apply-Logik zwischen lokalem Ordner und Tonie
|
||||||
```
|
```
|
||||||
|
|
||||||
## Entwicklung / Tests
|
## Entwicklung / Tests
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/arnef/toni-sync/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func loadConfig() (*config.Config, string, error) {
|
|
||||||
path := configPath
|
|
||||||
if path == "" {
|
|
||||||
path = config.DefaultPath()
|
|
||||||
}
|
|
||||||
cfg, err := config.Load(path)
|
|
||||||
return cfg, path, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func newConfigCmd() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "config",
|
|
||||||
Short: "Manage the mapping between local playlist folders and Kreativ-Tonies.",
|
|
||||||
}
|
|
||||||
cmd.AddCommand(newConfigAddCmd())
|
|
||||||
cmd.AddCommand(newConfigListCmd())
|
|
||||||
cmd.AddCommand(newConfigRemoveCmd())
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newConfigAddCmd() *cobra.Command {
|
|
||||||
var name, householdID, tonieID, folder, tonieName, playlistRef string
|
|
||||||
var noPrune bool
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "add",
|
|
||||||
Short: "Add or update a playlist/toni mapping.",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
if name == "" || householdID == "" || tonieID == "" || folder == "" {
|
|
||||||
return fmt.Errorf("--name, --household-id, --tonie-id and --folder are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, path, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Upsert(config.Mapping{
|
|
||||||
Name: name,
|
|
||||||
HouseholdID: householdID,
|
|
||||||
TonieID: tonieID,
|
|
||||||
TonieName: tonieName,
|
|
||||||
Folder: folder,
|
|
||||||
PlaylistRef: playlistRef,
|
|
||||||
Prune: !noPrune,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := config.Save(cfg, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Saved mapping '%s' -> tonie %s (folder: %s).\n", name, tonieID, folder)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(&name, "name", "", "Unique name for this mapping, e.g. 'peppa-wutz'.")
|
|
||||||
cmd.Flags().StringVar(&householdID, "household-id", "", "Household id (see `toni-sync tonies list`).")
|
|
||||||
cmd.Flags().StringVar(&tonieID, "tonie-id", "", "Creative Tonie id (see `toni-sync tonies list`).")
|
|
||||||
cmd.Flags().StringVar(&folder, "folder", "", "Local folder containing the exported audio files for this playlist.")
|
|
||||||
cmd.Flags().StringVar(&tonieName, "tonie-name", "", "Optional friendly name of the tonie (informational).")
|
|
||||||
cmd.Flags().StringVar(&playlistRef, "playlist-ref", "", "Optional reference to the Deezer playlist (URL/ID).")
|
|
||||||
cmd.Flags().BoolVar(&noPrune, "no-prune", false, "Never remove chapters that are missing locally.")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newConfigListCmd() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List all configured mappings.",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, _, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(cfg.Mappings) == 0 {
|
|
||||||
fmt.Println("No mappings configured yet. Use `toni-sync config add`.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, m := range cfg.Mappings {
|
|
||||||
fmt.Printf("%s: folder=%s tonie_id=%s household_id=%s prune=%v\n", m.Name, m.Folder, m.TonieID, m.HouseholdID, m.Prune)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newConfigRemoveCmd() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "remove NAME",
|
|
||||||
Short: "Remove a mapping by name.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, path, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !cfg.Remove(args[0]) {
|
|
||||||
return fmt.Errorf("no mapping named '%s' found", args[0])
|
|
||||||
}
|
|
||||||
if err := config.Save(cfg, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Removed mapping '%s'.\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/arnef/toni-sync/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newInitCmd() *cobra.Command {
|
||||||
|
var username, password string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Scaffold the library folder structure from your TonieCloud account.",
|
||||||
|
Long: "Creates '<root>/<household_id>/<freier_name> [id=<tonie_id>]' folders\n" +
|
||||||
|
"for every household and Kreativ-Tonie on your account. Folders that\n" +
|
||||||
|
"already exist (matched by tonie id) are left untouched so your local\n" +
|
||||||
|
"audio files are never affected.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
root, err := resolveRoot()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := resolveClient(username, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := library.Scaffold(client, root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
status := "created"
|
||||||
|
if !r.Created {
|
||||||
|
status = "exists"
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s\n", status, r.Folder)
|
||||||
|
}
|
||||||
|
fmt.Printf("Library ready at %s\n", root)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&username, "username", "", "TonieCloud username (default: env TONI_SYNC_USERNAME or prompt)")
|
||||||
|
cmd.Flags().StringVar(&password, "password", "", "TonieCloud password (default: env TONI_SYNC_PASSWORD or prompt)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+21
-3
@@ -7,17 +7,35 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var configPath string
|
var libraryRoot string
|
||||||
|
|
||||||
|
// resolveRoot returns the library root folder, honoring --root, then
|
||||||
|
// TONI_SYNC_ROOT, then falling back to the current working directory.
|
||||||
|
func resolveRoot() (string, error) {
|
||||||
|
if libraryRoot != "" {
|
||||||
|
return libraryRoot, nil
|
||||||
|
}
|
||||||
|
if env := os.Getenv("TONI_SYNC_ROOT"); env != "" {
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
return os.Getwd()
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: "toni-sync",
|
Use: "toni-sync",
|
||||||
Short: "Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies.",
|
Short: "Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies.",
|
||||||
|
Long: "toni-sync manages Kreativ-Tonies via a plain folder structure:\n\n" +
|
||||||
|
" <root>/<household_id>/<freier_name> [id=<tonie_id>]/*.mp3\n\n" +
|
||||||
|
"There is no separate config file - the folder structure itself is the\n" +
|
||||||
|
"configuration. Use `toni-sync init` to scaffold it automatically from\n" +
|
||||||
|
"your TonieCloud account, then place your audio files in the resulting\n" +
|
||||||
|
"tonie folders and run `toni-sync sync`.",
|
||||||
}
|
}
|
||||||
rootCmd.PersistentFlags().StringVar(&configPath, "config-path", "", "Path to the toni-sync config file (default: ~/.config/toni-sync/config.yaml)")
|
rootCmd.PersistentFlags().StringVar(&libraryRoot, "root", "", "Library root folder (default: env TONI_SYNC_ROOT or current directory)")
|
||||||
|
|
||||||
rootCmd.AddCommand(newToniesCmd())
|
rootCmd.AddCommand(newToniesCmd())
|
||||||
rootCmd.AddCommand(newConfigCmd())
|
rootCmd.AddCommand(newInitCmd())
|
||||||
rootCmd.AddCommand(newSyncCmd())
|
rootCmd.AddCommand(newSyncCmd())
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
|||||||
+31
-30
@@ -5,44 +5,44 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/arnef/toni-sync/internal/config"
|
"github.com/arnef/toni-sync/internal/library"
|
||||||
"github.com/arnef/toni-sync/internal/syncer"
|
"github.com/arnef/toni-sync/internal/syncer"
|
||||||
"github.com/arnef/toni-sync/internal/tonieapi"
|
"github.com/arnef/toni-sync/internal/tonieapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSyncCmd() *cobra.Command {
|
func newSyncCmd() *cobra.Command {
|
||||||
var syncAll, dryRun bool
|
var dryRun, noPrune bool
|
||||||
var username, password string
|
var username, password string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "sync [NAME]",
|
Use: "sync [FILTER]",
|
||||||
Short: "Sync a local playlist folder to its Kreativ-Tonie.",
|
Short: "Sync the library folder structure to the matching Kreativ-Tonies.",
|
||||||
Long: "Uploads new files, removes chapters no longer present locally\n" +
|
Long: "Discovers all '<root>/<household_id>/<freier_name> [id=<tonie_id>]'\n" +
|
||||||
"(unless the mapping has pruning disabled), and reorders chapters\n" +
|
"folders and syncs each one to its Kreativ-Tonie: uploads new files,\n" +
|
||||||
"to match the local folder's file order.",
|
"removes chapters no longer present locally (unless --no-prune is set),\n" +
|
||||||
|
"and reorders chapters to match the local folder's file order.\n\n" +
|
||||||
|
"FILTER optionally restricts syncing to tonies whose household id, tonie\n" +
|
||||||
|
"id, or name contains the given text (case-insensitive). Without a\n" +
|
||||||
|
"filter, all discovered tonies are synced.",
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
var name string
|
var filter string
|
||||||
if len(args) == 1 {
|
if len(args) == 1 {
|
||||||
name = args[0]
|
filter = args[0]
|
||||||
}
|
|
||||||
if !syncAll && name == "" {
|
|
||||||
return fmt.Errorf("specify a mapping NAME or use --all")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, _, err := loadConfig()
|
root, err := resolveRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var targets []config.Mapping
|
tonies, err := library.Discover(root)
|
||||||
if syncAll {
|
if err != nil {
|
||||||
targets = cfg.Mappings
|
return err
|
||||||
} else if m := cfg.Get(name); m != nil {
|
|
||||||
targets = []config.Mapping{*m}
|
|
||||||
}
|
}
|
||||||
if len(targets) == 0 {
|
tonies = library.Filter(tonies, filter)
|
||||||
return fmt.Errorf("no mapping named '%s' found", name)
|
if len(tonies) == 0 {
|
||||||
|
return fmt.Errorf("no matching tonie folders found under %s", root)
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := resolveClient(username, password)
|
client, err := resolveClient(username, password)
|
||||||
@@ -50,34 +50,35 @@ func newSyncCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range targets {
|
for _, t := range tonies {
|
||||||
if err := syncOne(client, m, dryRun); err != nil {
|
if err := syncOne(client, t, !noPrune, dryRun); err != nil {
|
||||||
return fmt.Errorf("syncing '%s': %w", m.Name, err)
|
return fmt.Errorf("syncing '%s' [id=%s]: %w", t.Name, t.TonieID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&syncAll, "all", false, "Sync all configured mappings.")
|
|
||||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would change, without uploading anything.")
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would change, without uploading anything.")
|
||||||
|
cmd.Flags().BoolVar(&noPrune, "no-prune", false, "Never remove chapters that are missing locally.")
|
||||||
cmd.Flags().StringVar(&username, "username", "", "TonieCloud username (default: env TONI_SYNC_USERNAME or prompt)")
|
cmd.Flags().StringVar(&username, "username", "", "TonieCloud username (default: env TONI_SYNC_USERNAME or prompt)")
|
||||||
cmd.Flags().StringVar(&password, "password", "", "TonieCloud password (default: env TONI_SYNC_PASSWORD or prompt)")
|
cmd.Flags().StringVar(&password, "password", "", "TonieCloud password (default: env TONI_SYNC_PASSWORD or prompt)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncOne(client *tonieapi.Client, m config.Mapping, dryRun bool) error {
|
func syncOne(client *tonieapi.Client, t library.Tonie, prune, dryRun bool) error {
|
||||||
fmt.Printf("== %s ==\n", m.Name)
|
fmt.Printf("== %s [id=%s] ==\n", t.Name, t.TonieID)
|
||||||
|
|
||||||
plan, err := syncer.BuildPlan(client, m)
|
target := syncer.Target{TonieID: t.TonieID, Folder: t.Folder, Prune: prune}
|
||||||
|
plan, err := syncer.BuildPlan(client, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
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 _, t := range plan.ToUpload {
|
for _, track := range plan.ToUpload {
|
||||||
fmt.Printf(" + %s\n", t.Title)
|
fmt.Printf(" + %s\n", track.Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(plan.ToRemove) > 0 {
|
if len(plan.ToRemove) > 0 {
|
||||||
@@ -96,7 +97,7 @@ func syncOne(client *tonieapi.Client, m config.Mapping, dryRun bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := syncer.ApplyPlan(client, m, plan); err != nil {
|
if err := syncer.ApplyPlan(client, target, plan); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(" Done.")
|
fmt.Println(" Done.")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ go 1.26.4
|
|||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/term v0.45.0
|
golang.org/x/term v0.45.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -11,7 +11,4 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
// Package config manages the local mapping between playlist folders and
|
|
||||||
// Kreativ-Tonies. Credentials are never stored here.
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mapping maps a local folder (e.g. an exported Deezer playlist) to a Kreativ-Tonie.
|
|
||||||
type Mapping struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
HouseholdID string `yaml:"household_id"`
|
|
||||||
TonieID string `yaml:"tonie_id"`
|
|
||||||
TonieName string `yaml:"tonie_name,omitempty"`
|
|
||||||
Folder string `yaml:"folder"`
|
|
||||||
PlaylistRef string `yaml:"playlist_ref,omitempty"`
|
|
||||||
Prune bool `yaml:"prune"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config is the full toni-sync configuration.
|
|
||||||
type Config struct {
|
|
||||||
Mappings []Mapping `yaml:"mappings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the mapping with the given name, if present.
|
|
||||||
func (c *Config) Get(name string) *Mapping {
|
|
||||||
for i := range c.Mappings {
|
|
||||||
if c.Mappings[i].Name == name {
|
|
||||||
return &c.Mappings[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert adds a new mapping or replaces an existing one with the same name.
|
|
||||||
func (c *Config) Upsert(m Mapping) {
|
|
||||||
for i := range c.Mappings {
|
|
||||||
if c.Mappings[i].Name == m.Name {
|
|
||||||
c.Mappings[i] = m
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Mappings = append(c.Mappings, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove deletes the mapping with the given name. Returns false if it was not found.
|
|
||||||
func (c *Config) Remove(name string) bool {
|
|
||||||
for i := range c.Mappings {
|
|
||||||
if c.Mappings[i].Name == name {
|
|
||||||
c.Mappings = append(c.Mappings[:i], c.Mappings[i+1:]...)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultPath returns the default config file location, honoring TONI_SYNC_CONFIG.
|
|
||||||
func DefaultPath() string {
|
|
||||||
if p := os.Getenv("TONI_SYNC_CONFIG"); p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
home = "."
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".config", "toni-sync", "config.yaml")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads the config from path, or returns an empty Config if it doesn't exist yet.
|
|
||||||
func Load(path string) (*Config, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return &Config{}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("reading config %s: %w", path, err)
|
|
||||||
}
|
|
||||||
var cfg Config
|
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing config %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save writes the config to path, creating parent directories as needed.
|
|
||||||
func Save(cfg *Config, path string) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
|
||||||
}
|
|
||||||
data, err := yaml.Marshal(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encoding config: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
||||||
return fmt.Errorf("writing config %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUpsertAddsNewMapping(t *testing.T) {
|
|
||||||
cfg := &Config{}
|
|
||||||
m := Mapping{Name: "a", HouseholdID: "h1", TonieID: "t1", Folder: "/tmp/a"}
|
|
||||||
cfg.Upsert(m)
|
|
||||||
got := cfg.Get("a")
|
|
||||||
if got == nil || got.TonieID != "t1" {
|
|
||||||
t.Fatalf("expected mapping to be added, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpsertReplacesExistingMapping(t *testing.T) {
|
|
||||||
cfg := &Config{}
|
|
||||||
cfg.Upsert(Mapping{Name: "a", HouseholdID: "h1", TonieID: "t1", Folder: "/tmp/a"})
|
|
||||||
cfg.Upsert(Mapping{Name: "a", HouseholdID: "h1", TonieID: "t2", Folder: "/tmp/a2"})
|
|
||||||
|
|
||||||
if len(cfg.Mappings) != 1 {
|
|
||||||
t.Fatalf("expected 1 mapping, got %d", len(cfg.Mappings))
|
|
||||||
}
|
|
||||||
if cfg.Get("a").TonieID != "t2" {
|
|
||||||
t.Fatalf("expected updated tonie id t2, got %s", cfg.Get("a").TonieID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveReturnsFalseWhenMissing(t *testing.T) {
|
|
||||||
cfg := &Config{}
|
|
||||||
if cfg.Remove("nope") {
|
|
||||||
t.Fatal("expected Remove to return false for missing mapping")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveAndLoadRoundtrip(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
|
|
||||||
cfg := &Config{}
|
|
||||||
cfg.Upsert(Mapping{
|
|
||||||
Name: "peppa",
|
|
||||||
HouseholdID: "h1",
|
|
||||||
TonieID: "t1",
|
|
||||||
Folder: "/tmp/peppa",
|
|
||||||
PlaylistRef: "https://example.com/playlist/1",
|
|
||||||
Prune: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := Save(cfg, path); err != nil {
|
|
||||||
t.Fatalf("Save failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load failed: %v", err)
|
|
||||||
}
|
|
||||||
m := loaded.Get("peppa")
|
|
||||||
if m == nil || m.TonieID != "t1" || m.PlaylistRef != "https://example.com/playlist/1" {
|
|
||||||
t.Fatalf("unexpected loaded mapping: %+v", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadMissingFileReturnsEmptyConfig(t *testing.T) {
|
|
||||||
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(cfg.Mappings) != 0 {
|
|
||||||
t.Fatalf("expected empty mappings, got %+v", cfg.Mappings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Package library discovers Kreativ-Tonie mappings from a folder structure,
|
||||||
|
// instead of a separate config file:
|
||||||
|
//
|
||||||
|
// <root>/<household_id>/<freier_name> [id=<tonie_id>]/*.mp3
|
||||||
|
//
|
||||||
|
// The household id is the top-level folder name, and each tonie folder
|
||||||
|
// name encodes both a free-form display name and the tonie id in brackets.
|
||||||
|
// This keeps the "configuration" fully visible and editable as plain
|
||||||
|
// folders on disk.
|
||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tonieDirPattern matches folder names like "Peppa Wutz [id=ef01-5678]".
|
||||||
|
var tonieDirPattern = regexp.MustCompile(`^(?P<name>.+) \[id=(?P<id>[^\]]+)\]$`)
|
||||||
|
|
||||||
|
// Tonie is a single discovered mapping between a local folder and a Kreativ-Tonie.
|
||||||
|
type Tonie struct {
|
||||||
|
HouseholdID string // taken from the top-level folder name
|
||||||
|
TonieID string // parsed from the "[id=...]" suffix of the folder name
|
||||||
|
Name string // free-form part of the folder name (before " [id=...]")
|
||||||
|
Folder string // absolute path to the folder containing the audio files
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTonieDir parses a single tonie folder name into name and id.
|
||||||
|
// Returns ok=false if the name doesn't match the expected "<name> [id=<id>]" pattern.
|
||||||
|
func ParseTonieDir(dirName string) (name, id string, ok bool) {
|
||||||
|
m := tonieDirPattern.FindStringSubmatch(dirName)
|
||||||
|
if m == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return m[1], m[2], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirName builds the folder name for a tonie: "<name> [id=<id>]".
|
||||||
|
func DirName(name, id string) string {
|
||||||
|
return fmt.Sprintf("%s [id=%s]", sanitizeName(name), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeName strips path separators and other characters that are unsafe in folder names.
|
||||||
|
func sanitizeName(name string) string {
|
||||||
|
replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-")
|
||||||
|
return strings.TrimSpace(replacer.Replace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover walks root and returns all tonie mappings found in it.
|
||||||
|
// Non-matching entries (files, folders without the "[id=...]" suffix) are skipped.
|
||||||
|
func Discover(root string) ([]Tonie, error) {
|
||||||
|
householdEntries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading library root %s: %w", root, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tonies []Tonie
|
||||||
|
for _, hEntry := range householdEntries {
|
||||||
|
if !hEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
householdID := hEntry.Name()
|
||||||
|
householdPath := filepath.Join(root, householdID)
|
||||||
|
|
||||||
|
tonieEntries, err := os.ReadDir(householdPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading household folder %s: %w", householdPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tEntry := range tonieEntries {
|
||||||
|
if !tEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, tonieID, ok := ParseTonieDir(tEntry.Name())
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tonies = append(tonies, Tonie{
|
||||||
|
HouseholdID: householdID,
|
||||||
|
TonieID: tonieID,
|
||||||
|
Name: name,
|
||||||
|
Folder: filepath.Join(householdPath, tEntry.Name()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tonies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter returns only the tonies matching the given filter string, matched
|
||||||
|
// case-insensitively as a substring against household id, tonie id, and name.
|
||||||
|
// An empty filter returns all tonies.
|
||||||
|
func Filter(tonies []Tonie, filter string) []Tonie {
|
||||||
|
if filter == "" {
|
||||||
|
return tonies
|
||||||
|
}
|
||||||
|
filter = strings.ToLower(filter)
|
||||||
|
var matched []Tonie
|
||||||
|
for _, t := range tonies {
|
||||||
|
if strings.Contains(strings.ToLower(t.HouseholdID), filter) ||
|
||||||
|
strings.Contains(strings.ToLower(t.TonieID), filter) ||
|
||||||
|
strings.Contains(strings.ToLower(t.Name), filter) {
|
||||||
|
matched = append(matched, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mkTonieDir(t *testing.T, householdPath, dirName string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(householdPath, dirName)
|
||||||
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonieDir(t *testing.T) {
|
||||||
|
name, id, ok := ParseTonieDir("Peppa Wutz [id=ef01-5678]")
|
||||||
|
if !ok || name != "Peppa Wutz" || id != "ef01-5678" {
|
||||||
|
t.Fatalf("unexpected parse result: name=%q id=%q ok=%v", name, id, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonieDirNoMatch(t *testing.T) {
|
||||||
|
if _, _, ok := ParseTonieDir("just a folder"); ok {
|
||||||
|
t.Fatal("expected no match for folder without [id=...] suffix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirName(t *testing.T) {
|
||||||
|
if got := DirName("Peppa/Wutz", "abc"); got != "Peppa-Wutz [id=abc]" {
|
||||||
|
t.Fatalf("unexpected dir name: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
household := filepath.Join(root, "household-1")
|
||||||
|
if err := os.MkdirAll(household, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mkTonieDir(t, household, "Peppa Wutz [id=tonie-1]")
|
||||||
|
mkTonieDir(t, household, "not-a-tonie-folder")
|
||||||
|
|
||||||
|
// A second household with one tonie.
|
||||||
|
household2 := filepath.Join(root, "household-2")
|
||||||
|
mkTonieDir(t, household2, "Gute Nacht [id=tonie-2]")
|
||||||
|
|
||||||
|
tonies, err := Discover(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tonies) != 2 {
|
||||||
|
t.Fatalf("expected 2 tonies, got %d: %+v", len(tonies), tonies)
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := map[string]Tonie{}
|
||||||
|
for _, tn := range tonies {
|
||||||
|
byID[tn.TonieID] = tn
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := byID["tonie-1"]
|
||||||
|
if !ok || got.HouseholdID != "household-1" || got.Name != "Peppa Wutz" {
|
||||||
|
t.Fatalf("unexpected tonie-1 entry: %+v", got)
|
||||||
|
}
|
||||||
|
got2, ok := byID["tonie-2"]
|
||||||
|
if !ok || got2.HouseholdID != "household-2" || got2.Name != "Gute Nacht" {
|
||||||
|
t.Fatalf("unexpected tonie-2 entry: %+v", got2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilter(t *testing.T) {
|
||||||
|
tonies := []Tonie{
|
||||||
|
{HouseholdID: "h1", TonieID: "t1", Name: "Peppa Wutz"},
|
||||||
|
{HouseholdID: "h2", TonieID: "t2", Name: "Gute Nacht"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := Filter(tonies, ""); len(got) != 2 {
|
||||||
|
t.Fatalf("empty filter should return all, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got := Filter(tonies, "peppa"); len(got) != 1 || got[0].TonieID != "t1" {
|
||||||
|
t.Fatalf("expected 1 match for 'peppa', got %+v", got)
|
||||||
|
}
|
||||||
|
if got := Filter(tonies, "h2"); len(got) != 1 || got[0].TonieID != "t2" {
|
||||||
|
t.Fatalf("expected 1 match for 'h2', got %+v", got)
|
||||||
|
}
|
||||||
|
if got := Filter(tonies, "nomatch"); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no matches, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/arnef/toni-sync/internal/tonieapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScaffoldClient is the subset of tonieapi.Client needed to scaffold a library folder tree.
|
||||||
|
type ScaffoldClient interface {
|
||||||
|
GetHouseholds() ([]tonieapi.Household, error)
|
||||||
|
GetCreativeTonies(householdID string) ([]tonieapi.CreativeTonie, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaffoldResult reports what Scaffold did for a single tonie.
|
||||||
|
type ScaffoldResult struct {
|
||||||
|
HouseholdID string
|
||||||
|
TonieID string
|
||||||
|
Name string
|
||||||
|
Folder string
|
||||||
|
Created bool // false if a matching folder already existed and was left untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scaffold creates the "<root>/<household_id>/<name> [id=<tonie_id>]" folder
|
||||||
|
// structure for every household and Kreativ-Tonie on the account. Existing
|
||||||
|
// tonie folders (matched by id, regardless of the current display name) are
|
||||||
|
// left untouched so local audio files are never affected.
|
||||||
|
func Scaffold(client ScaffoldClient, root string) ([]ScaffoldResult, error) {
|
||||||
|
households, err := client.GetHouseholds()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []ScaffoldResult
|
||||||
|
for _, h := range households {
|
||||||
|
householdPath := filepath.Join(root, h.ID)
|
||||||
|
if err := os.MkdirAll(householdPath, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating household folder %s: %w", householdPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingByID, err := existingTonieFoldersByID(householdPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tonies, err := client.GetCreativeTonies(h.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tonies {
|
||||||
|
if existingDir, ok := existingByID[t.ID]; ok {
|
||||||
|
results = append(results, ScaffoldResult{
|
||||||
|
HouseholdID: h.ID,
|
||||||
|
TonieID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
Folder: filepath.Join(householdPath, existingDir),
|
||||||
|
Created: false,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dirName := DirName(t.Name, t.ID)
|
||||||
|
tonieFolder := filepath.Join(householdPath, dirName)
|
||||||
|
if err := os.MkdirAll(tonieFolder, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating tonie folder %s: %w", tonieFolder, err)
|
||||||
|
}
|
||||||
|
results = append(results, ScaffoldResult{
|
||||||
|
HouseholdID: h.ID,
|
||||||
|
TonieID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
Folder: tonieFolder,
|
||||||
|
Created: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// existingTonieFoldersByID maps tonie id -> existing folder name within a household folder.
|
||||||
|
func existingTonieFoldersByID(householdPath string) (map[string]string, error) {
|
||||||
|
entries, err := os.ReadDir(householdPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading household folder %s: %w", householdPath, err)
|
||||||
|
}
|
||||||
|
byID := make(map[string]string)
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, id, ok := ParseTonieDir(e.Name()); ok {
|
||||||
|
byID[id] = e.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/arnef/toni-sync/internal/tonieapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeScaffoldClient struct {
|
||||||
|
households []tonieapi.Household
|
||||||
|
toniesByHouse map[string][]tonieapi.CreativeTonie
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeScaffoldClient) GetHouseholds() ([]tonieapi.Household, error) {
|
||||||
|
return f.households, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeScaffoldClient) GetCreativeTonies(householdID string) ([]tonieapi.CreativeTonie, error) {
|
||||||
|
return f.toniesByHouse[householdID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScaffoldCreatesFolders(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
client := &fakeScaffoldClient{
|
||||||
|
households: []tonieapi.Household{{ID: "h1", Name: "Family"}},
|
||||||
|
toniesByHouse: map[string][]tonieapi.CreativeTonie{
|
||||||
|
"h1": {{ID: "t1", HouseholdID: "h1", Name: "Peppa Wutz"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := Scaffold(client, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || !results[0].Created {
|
||||||
|
t.Fatalf("expected 1 created result, got %+v", results)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := filepath.Join(root, "h1", "Peppa Wutz [id=t1]")
|
||||||
|
if info, err := os.Stat(expected); err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("expected folder %s to exist: %v", expected, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScaffoldLeavesExistingFolderUntouched(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
// Simulate an existing folder that was renamed locally but keeps the id suffix.
|
||||||
|
existing := filepath.Join(root, "h1", "My Custom Name [id=t1]")
|
||||||
|
if err := os.MkdirAll(existing, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Put a marker file inside to make sure Scaffold doesn't touch it.
|
||||||
|
marker := filepath.Join(existing, "song.mp3")
|
||||||
|
if err := os.WriteFile(marker, []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeScaffoldClient{
|
||||||
|
households: []tonieapi.Household{{ID: "h1", Name: "Family"}},
|
||||||
|
toniesByHouse: map[string][]tonieapi.CreativeTonie{
|
||||||
|
"h1": {{ID: "t1", HouseholdID: "h1", Name: "Peppa Wutz (renamed in cloud)"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := Scaffold(client, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Created {
|
||||||
|
t.Fatalf("expected existing folder to be reported as not created, got %+v", results)
|
||||||
|
}
|
||||||
|
if results[0].Folder != existing {
|
||||||
|
t.Fatalf("expected folder %s, got %s", existing, results[0].Folder)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); err != nil {
|
||||||
|
t.Fatalf("expected local file to remain untouched: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/arnef/toni-sync/internal/config"
|
|
||||||
"github.com/arnef/toni-sync/internal/tonieapi"
|
"github.com/arnef/toni-sync/internal/tonieapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +20,15 @@ type TonieClient interface {
|
|||||||
SortChaptersOfTonie(tonie tonieapi.CreativeTonie, chapters []tonieapi.Chapter) 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
|
const maxTitleLength = 100
|
||||||
|
|
||||||
var audioExtensions = map[string]bool{
|
var audioExtensions = map[string]bool{
|
||||||
@@ -110,13 +118,13 @@ 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.
|
||||||
func BuildPlan(client TonieClient, m config.Mapping) (*Plan, error) {
|
func BuildPlan(client TonieClient, target Target) (*Plan, error) {
|
||||||
tonie, err := client.GetCreativeTonie(m.TonieID)
|
tonie, err := client.GetCreativeTonie(target.TonieID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks, err := ListLocalTracks(m.Folder)
|
tracks, err := ListLocalTracks(target.Folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -142,7 +150,7 @@ func BuildPlan(client TonieClient, m config.Mapping) (*Plan, error) {
|
|||||||
|
|
||||||
var toRemove []tonieapi.Chapter
|
var toRemove []tonieapi.Chapter
|
||||||
finalOrder := append([]string{}, orderedTitles...)
|
finalOrder := append([]string{}, orderedTitles...)
|
||||||
if m.Prune {
|
if target.Prune {
|
||||||
for _, c := range tonie.Chapters {
|
for _, c := range tonie.Chapters {
|
||||||
if !localTitles[c.Title] {
|
if !localTitles[c.Title] {
|
||||||
toRemove = append(toRemove, c)
|
toRemove = append(toRemove, c)
|
||||||
@@ -166,14 +174,14 @@ func BuildPlan(client TonieClient, m config.Mapping) (*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.
|
||||||
func ApplyPlan(client TonieClient, m config.Mapping, plan *Plan) error {
|
func ApplyPlan(client TonieClient, target Target, plan *Plan) error {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshed, err := client.GetCreativeTonie(m.TonieID)
|
refreshed, err := client.GetCreativeTonie(target.TonieID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("refetching tonie after upload: %w", err)
|
return fmt.Errorf("refetching tonie after upload: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/arnef/toni-sync/internal/config"
|
|
||||||
"github.com/arnef/toni-sync/internal/tonieapi"
|
"github.com/arnef/toni-sync/internal/tonieapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ func TestBuildPlanDetectsUploadNoRemoval(t *testing.T) {
|
|||||||
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
|
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
|
||||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
||||||
|
|
||||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
m := Target{TonieID: "t1", Folder: dir, Prune: true}
|
||||||
plan, err := BuildPlan(client, m)
|
plan, err := BuildPlan(client, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -101,7 +100,7 @@ func TestBuildPlanPrunesRemovedChapters(t *testing.T) {
|
|||||||
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
|
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
|
||||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
||||||
|
|
||||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
m := Target{TonieID: "t1", Folder: dir, Prune: true}
|
||||||
plan, err := BuildPlan(client, m)
|
plan, err := BuildPlan(client, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -116,7 +115,7 @@ func TestBuildPlanNoPruneKeepsStaleChapters(t *testing.T) {
|
|||||||
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
|
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
|
||||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
|
||||||
|
|
||||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: false}
|
m := Target{TonieID: "t1", Folder: dir, Prune: false}
|
||||||
plan, err := BuildPlan(client, m)
|
plan, err := BuildPlan(client, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -136,7 +135,7 @@ func TestApplyPlanUploadsAndSkipsReorderWhenAlreadyMatching(t *testing.T) {
|
|||||||
after := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "02 - Second"}})
|
after := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "02 - Second"}})
|
||||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{before, after}}
|
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{before, after}}
|
||||||
|
|
||||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
m := Target{TonieID: "t1", Folder: dir, Prune: true}
|
||||||
plan, err := BuildPlan(client, m)
|
plan, err := BuildPlan(client, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -158,7 +157,7 @@ func TestApplyPlanReordersWhenOrderDiffers(t *testing.T) {
|
|||||||
reversed := makeTonie([]tonieapi.Chapter{{ID: "c2", Title: "02 - Second"}, {ID: "c1", Title: "01 - First"}})
|
reversed := makeTonie([]tonieapi.Chapter{{ID: "c2", Title: "02 - Second"}, {ID: "c1", Title: "01 - First"}})
|
||||||
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{reversed, reversed}}
|
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{reversed, reversed}}
|
||||||
|
|
||||||
m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true}
|
m := Target{TonieID: "t1", Folder: dir, Prune: true}
|
||||||
plan, err := BuildPlan(client, m)
|
plan, err := BuildPlan(client, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user