// Package library discovers Kreativ-Tonie mappings from a folder structure, // instead of a separate config file: // // // [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.+) \[id=(?P[^\]]+)\]$`) // 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 " [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: " [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 }