diff --git a/docs/REPO_OVERVIEW.md b/docs/REPO_OVERVIEW.md index 3d3e25f..2984260 100644 --- a/docs/REPO_OVERVIEW.md +++ b/docs/REPO_OVERVIEW.md @@ -17,9 +17,10 @@ Assistant integration. | Path | What it is | |---|---| | `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. | +| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. | | `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. | | `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). | -| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats"). Not integrated with anything — no build system ties it in. Don't assume it reflects current product direction. | +| `claude-design/` | The interaction and visual spec the web front-end was built from ("Dolphin Beats"), as a standalone HTML mockup with hardcoded data. `web/` is the real implementation; the mockup is kept as the reference for the keyboard model and the styling. It also contains a second page, "Mein Zimmer" (room lights), which is **not** implemented. | ## How the pieces talk to each other @@ -34,6 +35,12 @@ Assistant integration. exact bytes of every effect payload — so a firmware change that breaks the host now breaks a test instead of just the LEDs. +- **`python-backend` ↔ `web/`**: JSON over HTTP for the library and for commands, plus a + push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents* + the physical buttons emit, so the web UI has no privileged path — and no way to get + out of step with a figure someone puts on the reader. See + `python-backend/README.md` for the endpoint list. + - **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three discoverable lights, a player sensor, a volume number, transport buttons, device triggers for every button and touch area, and a tag scanner for the RFID reader. It @@ -47,7 +54,17 @@ Assistant integration. python -m musicmouse --config /media/musicmouse/config.yml ``` -Or, with no hardware and no audio: +On a machine with no mouse attached — real audio and a real web UI, no serial port: + +```sh +python -m musicmouse --config ./config.yml --no-hardware +``` + +`general.serial_port: simulate` does the same thing from the config, and +`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and +both keys are required - omitting one is an error rather than an implicit simulation. + +Or with no hardware *and* no audio: ```sh python -m musicmouse --config ./config.yml --simulate @@ -57,19 +74,38 @@ The simulator runs the whole app against a fake serial link and a fake player, e interactively or from a scenario file. `python-backend/musicmouse.service` is the systemd unit for the device. +For front-end work, run the backend (either way above) and then: + +```sh +cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080 +``` + +`npm run build` writes `web/dist`, which `general.web.static_dir` points at in +production so one process serves both the UI and the API. + ## Config `python-backend/config.yml.example` documents the schema. In short: -- `general.{figure_folder, serial_port, baudrate, reconnect_interval, alsa_device, +- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device, min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness, - audio_extensions, mqtt.*}` + audio_extensions, mqtt.*, web.*}` - `figures..{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure; `colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or `"wNN"`. -Each figure plays `/
`, alphabetically by filename. Config is -validated with pydantic: unknown keys are errors, and every problem is reported at once. +`general.library.root` is the one path to the music. The shelves under it are fixed +names, not settings, because each has quirks the scanner knows about: + +``` +/Figuren/
/ one folder per figurine +/Musik/ - / albums, grouped by artist +/Hörbücher/ - / audiobooks, grouped by character +/Kinderpodcasts// shows, newest episode first +``` + +Config is validated with pydantic: unknown keys are errors, and every problem is +reported at once. ## Notes for agents @@ -80,3 +116,10 @@ validated with pydantic: unknown keys are errors, and every problem is reported - `python-backend/notebooks/` is university course material on chord recognition, not part of the app. - The firmware has no automated tests beyond a PlatformIO `native` env for LED effects. +- The front-end has `vitest` over the pure modules (`web/src/lib/`) and `tsc --noEmit`; + there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are + the two checks. +- The library index is cached under `general.library.cache`. It is keyed by file mtime + and size, so **a change to how the scanner derives a title, artist or series is + invisible until the cache is invalidated** — bump `_INDEX_VERSION` in + `musicmouse/library/cache.py` when you touch that logic. diff --git a/python-backend/.musicmouse-cache/covers/0772247eeb24.jpg b/python-backend/.musicmouse-cache/covers/0772247eeb24.jpg new file mode 100644 index 0000000..3f12eb8 Binary files /dev/null and b/python-backend/.musicmouse-cache/covers/0772247eeb24.jpg differ diff --git a/python-backend/.musicmouse-cache/covers/13293e87561e.jpg b/python-backend/.musicmouse-cache/covers/13293e87561e.jpg new file mode 100644 index 0000000..3652b71 Binary files /dev/null and b/python-backend/.musicmouse-cache/covers/13293e87561e.jpg differ diff --git a/python-backend/.musicmouse-cache/covers/22a6a01286bd.jpg b/python-backend/.musicmouse-cache/covers/22a6a01286bd.jpg new file mode 100644 index 0000000..3f12eb8 Binary files /dev/null and b/python-backend/.musicmouse-cache/covers/22a6a01286bd.jpg differ diff --git a/python-backend/.musicmouse-cache/covers/9bf448484625.jpg b/python-backend/.musicmouse-cache/covers/9bf448484625.jpg new file mode 100644 index 0000000..ca3ca2a Binary files /dev/null and b/python-backend/.musicmouse-cache/covers/9bf448484625.jpg differ diff --git a/python-backend/.musicmouse-cache/covers/9cc6a9f226cd.jpg b/python-backend/.musicmouse-cache/covers/9cc6a9f226cd.jpg new file mode 100644 index 0000000..28aaca2 Binary files /dev/null and b/python-backend/.musicmouse-cache/covers/9cc6a9f226cd.jpg differ diff --git a/python-backend/.musicmouse-cache/covers/c7dba5e954c4.jpg b/python-backend/.musicmouse-cache/covers/c7dba5e954c4.jpg new file mode 100644 index 0000000..f12935b Binary files /dev/null and b/python-backend/.musicmouse-cache/covers/c7dba5e954c4.jpg differ diff --git a/python-backend/.musicmouse-cache/covers/e95134365b97.jpg b/python-backend/.musicmouse-cache/covers/e95134365b97.jpg new file mode 100644 index 0000000..b2a60cd Binary files /dev/null and b/python-backend/.musicmouse-cache/covers/e95134365b97.jpg differ diff --git a/python-backend/.musicmouse-cache/index.json b/python-backend/.musicmouse-cache/index.json new file mode 100644 index 0000000..8c8a125 --- /dev/null +++ b/python-backend/.musicmouse-cache/index.json @@ -0,0 +1 @@ +{"version": 3, "albums": [{"id": "22a6a01286bd", "section": "Figuren", "kind": "music", "title": "Elefant", "artist": "Conni", "series": null, "figure": "elefant", "colors": ["#fccc00", "#ef978a", "#77a820"], "folder": "/home/martin/Music/Figuren/elefant", "cover": "/home/martin/code/musicmouse/python-backend/.musicmouse-cache/covers/22a6a01286bd.jpg", "tracks": [{"path": "/home/martin/Music/Figuren/elefant/01 - Meine beste Freundin Conni (Intro-Song).mp3", "title": "Meine beste Freundin Conni (Intro-Song)", "duration": 76.24480725623583, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/02 - Conni auf dem Bauernhof - Teil 01.mp3", "title": "Conni auf dem Bauernhof - Teil 01", "duration": 84.78267573696145, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/03 - Conni auf dem Bauernhof - Teil 02.mp3", "title": "Conni auf dem Bauernhof - Teil 02", "duration": 107.46775510204081, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/04 - Conni auf dem Bauernhof - Teil 03.mp3", "title": "Conni auf dem Bauernhof - Teil 03", "duration": 118.478820861678, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/05 - Conni auf dem Bauernhof - Teil 04.mp3", "title": "Conni auf dem Bauernhof - Teil 04", "duration": 84.09496598639456, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/06 - Conni auf dem Bauernhof - Teil 05.mp3", "title": "Conni auf dem Bauernhof - Teil 05", "duration": 82.21895691609977, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/07 - Conni auf dem Bauernhof - Teil 06.mp3", "title": "Conni auf dem Bauernhof - Teil 06", "duration": 84.74920634920635, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/08 - Conni auf dem Bauernhof - Teil 07.mp3", "title": "Conni auf dem Bauernhof - Teil 07", "duration": 87.97240362811792, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/09 - Conni auf dem Bauernhof - Teil 08.mp3", "title": "Conni auf dem Bauernhof - Teil 08", "duration": 82.2044671201814, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/10 - Conni auf dem Bauernhof - Teil 09.mp3", "title": "Conni auf dem Bauernhof - Teil 09", "duration": 84.5844671201814, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/11 - Conni auf dem Bauernhof - Teil 10.mp3", "title": "Conni auf dem Bauernhof - Teil 10", "duration": 82.63403628117914, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/12 - Conni auf dem Bauernhof - Teil 11.mp3", "title": "Conni auf dem Bauernhof - Teil 11", "duration": 92.93265306122449, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/13 - Conni auf dem Bauernhof - Teil 12.mp3", "title": "Conni auf dem Bauernhof - Teil 12", "duration": 110.72249433106576, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/14 - Conni auf dem Bauernhof - Teil 13.mp3", "title": "Conni auf dem Bauernhof - Teil 13", "duration": 117.79301587301588, "analysis": null}, {"path": "/home/martin/Music/Figuren/elefant/26 - Meine beste Freundin Conni (Outro-Song).mp3", "title": "Meine beste Freundin Conni (Outro-Song)", "duration": 77.09501133786848, "analysis": null}], "files": [["01 - Meine beste Freundin Conni (Intro-Song).mp3", 1438002, 1703947146], ["02 - Conni auf dem Bauernhof - Teil 01.mp3", 1574663, 1787777105], ["03 - Conni auf dem Bauernhof - Teil 02.mp3", 1937453, 1703947146], ["04 - Conni auf dem Bauernhof - Teil 03.mp3", 2113831, 1703947147], ["05 - Conni auf dem Bauernhof - Teil 04.mp3", 1563796, 1703947148], ["06 - Conni auf dem Bauernhof - Teil 05.mp3", 1533703, 1703947148], ["07 - Conni auf dem Bauernhof - Teil 06.mp3", 1574245, 1787777104], ["08 - Conni auf dem Bauernhof - Teil 07.mp3", 1625654, 1703947148], ["09 - Conni auf dem Bauernhof - Teil 08.mp3", 1533285, 1703947149], ["10 - Conni auf dem Bauernhof - Teil 09.mp3", 1571321, 1703947149], ["11 - Conni auf dem Bauernhof - Teil 10.mp3", 1540392, 1703947150], ["12 - Conni auf dem Bauernhof - Teil 11.mp3", 1705068, 1787777106], ["13 - Conni auf dem Bauernhof - Teil 12.mp3", 1989699, 1703947150], ["14 - Conni auf dem Bauernhof - Teil 13.mp3", 2102966, 1787777105], ["26 - Meine beste Freundin Conni (Outro-Song).mp3", 1451797, 1787777106]]}, {"id": "0772247eeb24", "section": "Figuren", "kind": "music", "title": "Fuchs", "artist": "Conni", "series": null, "figure": "fuchs", "colors": ["#fccc00", "#ef978a", "#77a820"], "folder": "/home/martin/Music/Figuren/fuchs", "cover": "/home/martin/code/musicmouse/python-backend/.musicmouse-cache/covers/0772247eeb24.jpg", "tracks": [{"path": "/home/martin/Music/Figuren/fuchs/01 - Meine beste Freundin Conni (Intro-Song).mp3", "title": "Meine beste Freundin Conni (Intro-Song)", "duration": 76.24480725623583, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/15 - Conni und das neue Baby - Teil 01.mp3", "title": "Conni und das neue Baby - Teil 01", "duration": 97.74600907029479, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/16 - Conni und das neue Baby - Teil 02.mp3", "title": "Conni und das neue Baby - Teil 02", "duration": 112.43984126984127, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/17 - Conni und das neue Baby - Teil 03.mp3", "title": "Conni und das neue Baby - Teil 03", "duration": 97.56689342403628, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/18 - Conni und das neue Baby - Teil 04.mp3", "title": "Conni und das neue Baby - Teil 04", "duration": 80.64469387755102, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/19 - Conni und das neue Baby - Teil 05.mp3", "title": "Conni und das neue Baby - Teil 05", "duration": 83.66587301587302, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/20 - Conni und das neue Baby - Teil 06.mp3", "title": "Conni und das neue Baby - Teil 06", "duration": 98.79943310657596, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/21 - Conni und das neue Baby - Teil 07.mp3", "title": "Conni und das neue Baby - Teil 07", "duration": 98.26977324263038, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/22 - Conni und das neue Baby - Teil 08.mp3", "title": "Conni und das neue Baby - Teil 08", "duration": 92.1621768707483, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/23 - Conni und das neue Baby - Teil 09.mp3", "title": "Conni und das neue Baby - Teil 09", "duration": 97.3885941043084, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/24 - Conni und das neue Baby - Teil 10.mp3", "title": "Conni und das neue Baby - Teil 10", "duration": 112.1337641723356, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/25 - Conni und das neue Baby - Teil 11.mp3", "title": "Conni und das neue Baby - Teil 11", "duration": 115.28875283446712, "analysis": null}, {"path": "/home/martin/Music/Figuren/fuchs/26 - Meine beste Freundin Conni (Outro-Song).mp3", "title": "Meine beste Freundin Conni (Outro-Song)", "duration": 77.09501133786848, "analysis": null}], "files": [["01 - Meine beste Freundin Conni (Intro-Song).mp3", 1438002, 1710265506], ["15 - Conni und das neue Baby - Teil 01.mp3", 1781973, 1787777107], ["16 - Conni und das neue Baby - Teil 02.mp3", 2017285, 1710265507], ["17 - Conni und das neue Baby - Teil 03.mp3", 1779047, 1787777107], ["18 - Conni und das neue Baby - Teil 04.mp3", 1508627, 1787777107], ["19 - Conni und das neue Baby - Teil 05.mp3", 1556693, 1710265507], ["20 - Conni und das neue Baby - Teil 06.mp3", 1799109, 1710265507], ["21 - Conni und das neue Baby - Teil 07.mp3", 1790332, 1710265507], ["22 - Conni und das neue Baby - Teil 08.mp3", 1692947, 1710265507], ["23 - Conni und das neue Baby - Teil 09.mp3", 1776539, 1787777109], ["24 - Conni und das neue Baby - Teil 10.mp3", 2012269, 1710265507], ["25 - Conni und das neue Baby - Teil 11.mp3", 2062842, 1710265507], ["26 - Meine beste Freundin Conni (Outro-Song).mp3", 1451797, 1787777108]]}, {"id": "c7dba5e954c4", "section": "Figuren", "kind": "music", "title": "Hase", "artist": "Minimusiker", "series": null, "figure": "hase", "colors": ["#3896b1", "#f0cc75", "#eb9337"], "folder": "/home/martin/Music/Figuren/hase", "cover": "/home/martin/code/musicmouse/python-backend/.musicmouse-cache/covers/c7dba5e954c4.jpg", "tracks": [{"path": "/home/martin/Music/Figuren/hase/01 - Minimusikersong.mp3", "title": "Minimusikersong", "duration": 155.01061224489797, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/02 - Faultier.mp3", "title": "Faultier", "duration": 193.8808163265306, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/03 - Kann ich nicht.mp3", "title": "Kann ich nicht", "duration": 179.98367346938775, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/04 - Ich und Du.mp3", "title": "Ich und Du", "duration": 209.81551020408162, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/05 - Alles wird gut.mp3", "title": "Alles wird gut", "duration": 185.18204081632652, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/06 - Ich wär so gern ein Astronaut.mp3", "title": "Ich wär so gern ein Astronaut", "duration": 162.1942857142857, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/07 - So wie Du.mp3", "title": "So wie Du", "duration": 164.64979591836735, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/08 - Ich mach so gern Musik.mp3", "title": "Ich mach so gern Musik", "duration": 171.1804081632653, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/09 - Mein Style.mp3", "title": "Mein Style", "duration": 185.12979591836734, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/10 - Schussel und ich.mp3", "title": "Schussel und ich", "duration": 126.35428571428571, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/11 - Draussen spielen.mp3", "title": "Draussen spielen", "duration": 171.67673469387756, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/12 - Was reimt sich auf ''Wissen''.mp3", "title": "Was reimt sich auf ''Wissen''", "duration": 160.07836734693876, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/13 - Zickenalarm.mp3", "title": "Zickenalarm", "duration": 150.3869387755102, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/14 - Ich bin ein Monster.mp3", "title": "Ich bin ein Monster", "duration": 203.23265306122448, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/15 - Tröste mich.mp3", "title": "Tröste mich", "duration": 210.1812244897959, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/16 - Feunde fürs Leben.mp3", "title": "Feunde fürs Leben", "duration": 240.77061224489796, "analysis": null}, {"path": "/home/martin/Music/Figuren/hase/17 - Augen zu.mp3", "title": "Augen zu", "duration": 145.37142857142857, "analysis": null}], "files": [["01 - Minimusikersong.mp3", 2578552, 1709726738], ["02 - Faultier.mp3", 3200462, 1709726738], ["03 - Kann ich nicht.mp3", 2978119, 1709726739], ["04 - Ich und Du.mp3", 3455421, 1787777109], ["05 - Alles wird gut.mp3", 3061293, 1709726738], ["06 - Ich wär so gern ein Astronaut.mp3", 2693519, 1787777111], ["07 - So wie Du.mp3", 2732781, 1709726739], ["08 - Ich mach so gern Musik.mp3", 2837283, 1709726739], ["09 - Mein Style.mp3", 3060449, 1709726738], ["10 - Schussel und ich.mp3", 2120055, 1709726738], ["11 - Draussen spielen.mp3", 2845214, 1709726739], ["12 - Was reimt sich auf ''Wissen''.mp3", 2659666, 1709726738], ["13 - Zickenalarm.mp3", 2504568, 1787777110], ["14 - Ich bin ein Monster.mp3", 3350115, 1787777112], ["15 - Tröste mich.mp3", 3461276, 1787777110], ["16 - Feunde fürs Leben.mp3", 3950718, 1787777111], ["17 - Augen zu.mp3", 2424327, 1709726738]]}, {"id": "9cc6a9f226cd", "section": "Figuren", "kind": "music", "title": "Omnom", "artist": "Simone Sommerland", "series": null, "figure": "omnom", "colors": ["#426aaa", "#858152", "#d63995"], "folder": "/home/martin/Music/Figuren/omnom", "cover": "/home/martin/code/musicmouse/python-backend/.musicmouse-cache/covers/9cc6a9f226cd.jpg", "tracks": [{"path": "/home/martin/Music/Figuren/omnom/01 - Simone Sommerland - Ki-Ka-Kinderturnen.mp3", "title": "Ki-Ka-Kinderturnen", "duration": 99.30562358276644, "analysis": null}, {"path": "/home/martin/Music/Figuren/omnom/02 Unser kleiner Bär im Zoo.mp3", "title": "Unser kleiner Bär im Zoo", "duration": 103.05333333333333, "analysis": null}, {"path": "/home/martin/Music/Figuren/omnom/03 - Rommel-Bommel (Rummelbummel).mp3", "title": "Rommel-Bommel (Rummelbummel)", "duration": 135.89333333333335, "analysis": null}, {"path": "/home/martin/Music/Figuren/omnom/04- So ein schöner Tag (Fliegerlied).mp3", "title": "So ein schöner Tag (Fliegerlied)", "duration": 199.70612244897958, "analysis": null}, {"path": "/home/martin/Music/Figuren/omnom/06 - Das rote Pferd.mp3", "title": "Das rote Pferd", "duration": 105.44, "analysis": null}, {"path": "/home/martin/Music/Figuren/omnom/10 - LUIS VILLEGAS - Banana Bay.mp3", "title": "Banana Bay", "duration": 197.53795918367348, "analysis": null}, {"path": "/home/martin/Music/Figuren/omnom/11 - Wer hat an der Uhr gedreht_.mp3", "title": "Wer hat an der Uhr gedreht?", "duration": 67.68326530612245, "analysis": null}, {"path": "/home/martin/Music/Figuren/omnom/12 Alle Leut.mp3", "title": "Alle Leut'", "duration": 67.90666666666667, "analysis": null}], "files": [["01 - Simone Sommerland - Ki-Ka-Kinderturnen.mp3", 1701020, 1709726741], ["02 Unser kleiner Bär im Zoo.mp3", 1754367, 1709726740], ["03 - Rommel-Bommel (Rummelbummel).mp3", 2279749, 1709726741], ["04- So ein schöner Tag (Fliegerlied).mp3", 3323372, 1787777116], ["06 - Das rote Pferd.mp3", 1792383, 1709726741], ["10 - LUIS VILLEGAS - Banana Bay.mp3", 3330251, 1787777115], ["11 - Wer hat an der Uhr gedreht_.mp3", 1210998, 1709726741], ["12 Alle Leut.mp3", 1191766, 1787777114]]}, {"id": "13293e87561e", "section": "Figuren", "kind": "music", "title": "Schneemann", "artist": "Simone Sommerland", "series": null, "figure": "schneemann", "colors": ["#8bc1bb", "#567e48", "#0da9b7"], "folder": "/home/martin/Music/Figuren/schneemann", "cover": "/home/martin/code/musicmouse/python-backend/.musicmouse-cache/covers/13293e87561e.jpg", "tracks": [{"path": "/home/martin/Music/Figuren/schneemann/01 - Lichterkinder Lichterkinder.mp3", "title": "Lichterkinder", "duration": 241.16244897959183, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/02 - Lichterkinder Der Herbst ist da.mp3", "title": "Der Herbst ist da", "duration": 109.97551020408163, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/02 - Lichterkinder St. Martin ritt durch Schnee und Wind.mp3", "title": "St. Martin ritt durch Schnee und Wind", "duration": 169.63918367346938, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/03 - Lichterkinder Laterne, Laterne.mp3", "title": "Laterne, Laterne", "duration": 129.35836734693876, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z01 Rolf Zuckowski - In der Weihnachtsbäckerei.mp3", "title": "In der Weihnachtsbäckerei", "duration": 172.72163265306122, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z02 Simone Sommerland - O Tannenbaum.mp3", "title": "O Tannenbaum", "duration": 105.18666666666667, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z03 - Lasst uns froh und munter sein.mp3", "title": "Lasst uns froh und munter sein", "duration": 101.12, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z04 - Kling, Glöckchen, klingelingeling.mp3", "title": "Kling, Glöckchen, klingelingeling", "duration": 121.15591836734694, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z05 - Rockin' Around the Christmas Tree.mp3", "title": "Rockin' Around the Christmas Tree", "duration": 123.27183673469388, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z06 - Ihr Kinderlein kommet.mp3", "title": "Ihr Kinderlein kommet", "duration": 180.5061224489796, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z07 - Am Weihnachtsbaum die Lichter brennen.mp3", "title": "Am Weihnachtsbaum die Lichter brennen", "duration": 143.54285714285714, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z08 - Alle Jahre wieder.mp3", "title": "Alle Jahre wieder", "duration": 111.25551020408163, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z09 - Schneeflöckchen, Weißröckchen.mp3", "title": "Schneeflöckchen, Weißröckchen", "duration": 85.60326530612245, "analysis": null}, {"path": "/home/martin/Music/Figuren/schneemann/z10 - Der kleine Trommler.mp3", "title": "Der kleine Trommler", "duration": 183.40571428571428, "analysis": null}], "files": [["01 - Lichterkinder Lichterkinder.mp3", 4147564, 1787777117], ["02 - Lichterkinder Der Herbst ist da.mp3", 2046478, 1787777118], ["02 - Lichterkinder St. Martin ritt durch Schnee und Wind.mp3", 3002070, 1761377002], ["03 - Lichterkinder Laterne, Laterne.mp3", 2356913, 1761377009], ["z01 Rolf Zuckowski - In der Weihnachtsbäckerei.mp3", 3018992, 1787777118], ["z02 Simone Sommerland - O Tannenbaum.mp3", 1841427, 1787777116], ["z03 - Lasst uns froh und munter sein.mp3", 1836764, 1787777116], ["z04 - Kling, Glöckchen, klingelingeling.mp3", 2157344, 1732098845], ["z05 - Rockin' Around the Christmas Tree.mp3", 2191201, 1732098849], ["z06 - Ihr Kinderlein kommet.mp3", 3106925, 1732098852], ["z07 - Am Weihnachtsbaum die Lichter brennen.mp3", 2515545, 1732098854], ["z08 - Alle Jahre wieder.mp3", 1998908, 1732098853], ["z09 - Schneeflöckchen, Weißröckchen.mp3", 1588495, 1787777119], ["z10 - Der kleine Trommler.mp3", 3153315, 1787777116]]}, {"id": "46d639444270", "section": "Musik", "kind": "music", "title": "Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht ? - Tsch Tschu Wa - Das Rote Pferd - D", "artist": "Die Liederkinder", "series": null, "figure": null, "colors": ["#a5cc82", "#004f05", "#083f8d"], "folder": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D", "cover": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/cover.jpg", "tracks": [{"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/001 - Wer hat an der Uhr gedreht _.mp3", "title": "Wer hat an der Uhr gedreht ?", "duration": 146.2334693877551, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/002 - 99 Luftballons.mp3", "title": "99 Luftballons", "duration": 145.11020408163265, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/003 - So ein schöner Tag.mp3", "title": "So ein schöner Tag", "duration": 135.47102040816327, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/004 - Fuchs Du hast die Gans gestohlen.mp3", "title": "Fuchs Du hast die Gans gestohlen", "duration": 118.88326530612245, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/005 - Der Gorilla mit der Sonnbrille.mp3", "title": "Der Gorilla mit der Sonnbrille", "duration": 137.79591836734693, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/006 - Der Kuckuck und der Esel.mp3", "title": "Der Kuckuck und der Esel", "duration": 106.71020408163265, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/007 - Der Mai ist gekommen.mp3", "title": "Der Mai ist gekommen", "duration": 169.14285714285714, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/008 - Es war eine Mutter.mp3", "title": "Es war eine Mutter", "duration": 116.24489795918367, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/009 - Frohe Ostern.mp3", "title": "Frohe Ostern", "duration": 144.16979591836736, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/010 - Grün grün grün sind alle meine Kinder.mp3", "title": "Grün grün grün sind alle meine Kinder", "duration": 141.92326530612246, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/011 - Weißt Du wieviel Sternlein stehen.mp3", "title": "Weißt Du wieviel Sternlein stehen", "duration": 142.23673469387754, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/012 - Drei Chinesen mit dem Kontrabass.mp3", "title": "Drei Chinesen mit dem Kontrabass", "duration": 143.15102040816328, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/013 - Ein Lama in Yokohama.mp3", "title": "Ein Lama in Yokohama", "duration": 115.8269387755102, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/014 - Benjamin Blümchen.mp3", "title": "Benjamin Blümchen", "duration": 115.2, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/015 - Biene Maja.mp3", "title": "Biene Maja", "duration": 147.43510204081633, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/016 - Bruder Jakob.mp3", "title": "Bruder Jakob", "duration": 74.73632653061225, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/017 - Old Mc Donald hat ein Haus.mp3", "title": "Old Mc Donald hat ein Haus", "duration": 93.20489795918367, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/018 - Pinocchio.mp3", "title": "Pinocchio", "duration": 181.23755102040818, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/019 - Polonäse Blankenese.mp3", "title": "Polonäse Blankenese", "duration": 160.86204081632653, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/020 - Ringel, Ringel Reihe.mp3", "title": "Ringel, Ringel Reihe", "duration": 102.87020408163265, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/021 - Guten Abend gute Nacht.mp3", "title": "Guten Abend gute Nacht", "duration": 156.91755102040815, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/022 - Guter Mond du gehst so stille.mp3", "title": "Guter Mond du gehst so stille", "duration": 125.72734693877551, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/023 - Hände waschen.mp3", "title": "Hände waschen", "duration": 138.55346938775511, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/024 - Wie schön dass Du geboren bist.mp3", "title": "Wie schön dass Du geboren bist", "duration": 153.36489795918368, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/025 - Zehn kleine Gartenzwerge.mp3", "title": "Zehn kleine Gartenzwerge", "duration": 213.7338775510204, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/026 - Widele wedele hinterm Städtele.mp3", "title": "Widele wedele hinterm Städtele", "duration": 152.32, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/027 - Wir gehen jetzt im Kreise.mp3", "title": "Wir gehen jetzt im Kreise", "duration": 93.57061224489796, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/028 - Brüderchen komm tanz mit mir.mp3", "title": "Brüderchen komm tanz mit mir", "duration": 176.03918367346938, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/029 - Alle Leut´.mp3", "title": "Alle Leut´", "duration": 135.9673469387755, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/030 - Alles Gute (zum Geburtstag).mp3", "title": "Alles Gute (zum Geburtstag)", "duration": 84.7934693877551, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/031 - Auf der Mauer af der Lauer.mp3", "title": "Auf der Mauer af der Lauer", "duration": 181.99510204081633, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/032 - Das Wandern ist des Müllers Lust.mp3", "title": "Das Wandern ist des Müllers Lust", "duration": 135.62775510204082, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/033 - Auf der grünen Wiese (das Karussell Lied).mp3", "title": "Auf der grünen Wiese (das Karussell Lied)", "duration": 134.13877551020408, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/034 - Backe backe Kuchen.mp3", "title": "Backe backe Kuchen", "duration": 104.35918367346939, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/035 - Bunt sind schon die Wälder.mp3", "title": "Bunt sind schon die Wälder", "duration": 110.78530612244899, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/036 - Cowboys und Indianer.mp3", "title": "Cowboys und Indianer", "duration": 145.52816326530612, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/037 - Auf unsrer Wiese gehet was.mp3", "title": "Auf unsrer Wiese gehet was", "duration": 122.33142857142857, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/038 - Hänschen Klein.mp3", "title": "Hänschen Klein", "duration": 142.44571428571427, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/039 - Das ABC Lied.mp3", "title": "Das ABC Lied", "duration": 123.1934693877551, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/040 - Das Lied der Schlümpfe.mp3", "title": "Das Lied der Schlümpfe", "duration": 127.34693877551021, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/041 - Das rote Pferd.mp3", "title": "Das rote Pferd", "duration": 149.76, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/042 - Die Jahresuhr.mp3", "title": "Die Jahresuhr", "duration": 127.24244897959184, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/043 - Der Mond ist aufgegangen.mp3", "title": "Der Mond ist aufgegangen", "duration": 170.78857142857143, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/044 - Der Pirat.mp3", "title": "Der Pirat", "duration": 150.7265306122449, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/045 - Der Sandmann ist da.mp3", "title": "Der Sandmann ist da", "duration": 98.61224489795919, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/046 - Die Affen rasen durch den Wald.mp3", "title": "Die Affen rasen durch den Wald", "duration": 200.51591836734693, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/047 - Auf der Schwäb'schen Eisenbahn.mp3", "title": "Auf der Schwäb'schen Eisenbahn", "duration": 138.44897959183675, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/048 - Dornröschen.mp3", "title": "Dornröschen", "duration": 157.49224489795918, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/049 - Die Tiroler sind lustig.mp3", "title": "Die Tiroler sind lustig", "duration": 140.094693877551, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/050 - Ein Mann der sich Kolumbus nannt.mp3", "title": "Ein Mann der sich Kolumbus nannt", "duration": 194.63836734693876, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/051 - Ein Männlein steht im Walde.mp3", "title": "Ein Männlein steht im Walde", "duration": 112.09142857142857, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/052 - Ein Vogel wollte Hochzeit machen.mp3", "title": "Ein Vogel wollte Hochzeit machen", "duration": 219.5591836734694, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/053 - Eine Seefahrt die ist lustig.mp3", "title": "Eine Seefahrt die ist lustig", "duration": 139.83346938775512, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/054 - Ententanz.mp3", "title": "Ententanz", "duration": 113.13632653061225, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/055 - Es klappert die Mühle.mp3", "title": "Es klappert die Mühle", "duration": 129.22775510204082, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/056 - Es schneit.mp3", "title": "Es schneit", "duration": 135.86285714285714, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/057 - Es tanzt ein Bi Ba-Butzeman.mp3", "title": "Es tanzt ein Bi Ba-Butzeman", "duration": 119.3273469387755, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/058 - Heidi.mp3", "title": "Heidi", "duration": 153.3126530612245, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/059 - Heute kann es regnen.mp3", "title": "Heute kann es regnen", "duration": 177.00571428571428, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/060 - Hey Pippi Langstrumpf.mp3", "title": "Hey Pippi Langstrumpf", "duration": 129.0448979591837, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/061 - Hier kommt die Maus.mp3", "title": "Hier kommt die Maus", "duration": 122.5665306122449, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/062 - Hopp, hopp, hopp, Pferdchen lauf galopp.mp3", "title": "Hopp, hopp, hopp, Pferdchen lauf galopp", "duration": 123.45469387755102, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/063 - Hänsel und Gretel.mp3", "title": "Hänsel und Gretel", "duration": 147.43510204081633, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/064 - Häschen in der Grube.mp3", "title": "Häschen in der Grube", "duration": 147.43510204081633, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/065 - Horch was kommt von draussen rein.mp3", "title": "Horch was kommt von draussen rein", "duration": 128.20897959183674, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/066 - Hurrah, hurrah der Pumuckl ist da.mp3", "title": "Hurrah, hurrah der Pumuckl ist da", "duration": 134.7657142857143, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/067 - Ich geh mit meiner Laterne.mp3", "title": "Ich geh mit meiner Laterne", "duration": 100.17959183673469, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/068 - Ihr Kinderlein kommet.mp3", "title": "Ihr Kinderlein kommet", "duration": 171.8595918367347, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/069 - Im Frühtau zu Berge.mp3", "title": "Im Frühtau zu Berge", "duration": 117.99510204081632, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/070 - In einem kleinen Apfel.mp3", "title": "In einem kleinen Apfel", "duration": 92.89142857142858, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/071 - Jetzt fahrn ́wir übern ́ See.mp3", "title": "Jetzt fahrn ́wir übern ́ See", "duration": 156.86530612244897, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/072 - Kuckuck, Kuckuck.mp3", "title": "Kuckuck, Kuckuck", "duration": 115.38285714285715, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/073 - Lasst uns Froh und Munter sein.mp3", "title": "Lasst uns Froh und Munter sein", "duration": 135.88897959183674, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/074 - Laterne, Laterne, Sonne, Mond und Sterne.mp3", "title": "Laterne, Laterne, Sonne, Mond und Sterne", "duration": 138.8669387755102, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/075 - Mein Hut der hat drei Ecken.mp3", "title": "Mein Hut der hat drei Ecken", "duration": 127.11183673469388, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/076 - Meine Oma fährt im Hühnerstall Motorrad.mp3", "title": "Meine Oma fährt im Hühnerstall Motorrad", "duration": 142.15836734693877, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/077 - Oh Du lieber Augustin.mp3", "title": "Oh Du lieber Augustin", "duration": 99.26530612244898, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/078 - Oh Suzanna.mp3", "title": "Oh Suzanna", "duration": 127.3730612244898, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/079 - Rommel Bommel.mp3", "title": "Rommel Bommel", "duration": 139.1281632653061, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/080 - Rucki Zucki.mp3", "title": "Rucki Zucki", "duration": 131.4742857142857, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/081 - Sankt Martin ritt durch Schnee und Wind.mp3", "title": "Sankt Martin ritt durch Schnee und Wind", "duration": 122.2530612244898, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/082 - Schlaf Kindlein schlaf.mp3", "title": "Schlaf Kindlein schlaf", "duration": 162.8995918367347, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/083 - Schnappi.mp3", "title": "Schnappi", "duration": 129.9330612244898, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/084 - Schneeflöckchen Weißröckchen.mp3", "title": "Schneeflöckchen Weißröckchen", "duration": 137.14285714285714, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/085 - Summ summ summ.mp3", "title": "Summ summ summ", "duration": 108.25142857142858, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/086 - Sur le Pont d ́Avignon.mp3", "title": "Sur le Pont d ́Avignon", "duration": 131.00408163265305, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/087 - Theo Theo.mp3", "title": "Theo Theo", "duration": 213.86448979591836, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/088 - Trarira der Sommer der ist da.mp3", "title": "Trarira der Sommer der ist da", "duration": 128.02612244897958, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/089 - Tschu Tschu wa.mp3", "title": "Tschu Tschu wa", "duration": 200.25469387755103, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/090 - Turaluraluralu ich mach bubu, was machst Du.mp3", "title": "Turaluraluralu ich mach bubu, was machst Du", "duration": 161.72408163265305, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/091 - Vielen Dank für die Blumen.mp3", "title": "Vielen Dank für die Blumen", "duration": 183.09224489795918, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/092 - Von den blauen Bergen kommen wir (yuppie ya ya).mp3", "title": "Von den blauen Bergen kommen wir (yuppie ya ya)", "duration": 148.63673469387754, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/093 - Taler, Taler du musst wandern.mp3", "title": "Taler, Taler du musst wandern", "duration": 131.70938775510203, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/094 - Was müssen das für Bäume sein.mp3", "title": "Was müssen das für Bäume sein", "duration": 142.49795918367346, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/095 - Wer hat die schönsten Schäfchen.mp3", "title": "Wer hat die schönsten Schäfchen", "duration": 131.4742857142857, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/096 - Wer will fleissige Handwerker sehn.mp3", "title": "Wer will fleissige Handwerker sehn", "duration": 138.39673469387756, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/097 - Wickie.mp3", "title": "Wickie", "duration": 112.77061224489796, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/098 - Zehn kleine Zappelfinger.mp3", "title": "Zehn kleine Zappelfinger", "duration": 90.33142857142857, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/099 - Zeigt her Eure Füße.mp3", "title": "Zeigt her Eure Füße", "duration": 103.0269387755102, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/100 - Alle Vöglein sind schon da.mp3", "title": "Alle Vöglein sind schon da", "duration": 187.97714285714287, "analysis": null}, {"path": "/home/martin/Music/Musik/Die Liederkinder - Die 101 besten Kinderlieder - Heidi - Schnappi - Die Jahresuhr - Wer hat an der Uhr gedreht _ - Tsch Tschu Wa - Das Rote Pferd - D/101 - Alle meine Entchen.mp3", "title": "Alle meine Entchen", "duration": 79.15102040816326, "analysis": null}], "files": [["001 - Wer hat an der Uhr gedreht _.mp3", 2413169, 1731760737], ["002 - 99 Luftballons.mp3", 2395168, 1731760738], ["003 - So ein schöner Tag.mp3", 2240950, 1731760738], ["004 - Fuchs Du hast die Gans gestohlen.mp3", 1975573, 1731760738], ["005 - Der Gorilla mit der Sonnbrille.mp3", 2278172, 1731760739], ["006 - Der Kuckuck und der Esel.mp3", 1780789, 1731760739], ["007 - Der Mai ist gekommen.mp3", 2779702, 1731760740], ["008 - Es war eine Mutter.mp3", 1933331, 1731760740], ["009 - Frohe Ostern.mp3", 2380117, 1731760741], ["010 - Grün grün grün sind alle meine Kinder.mp3", 2344226, 1731760741], ["011 - Weißt Du wieviel Sternlein stehen.mp3", 2349232, 1731760741], ["012 - Drei Chinesen mit dem Kontrabass.mp3", 2363860, 1731760742], ["013 - Ein Lama in Yokohama.mp3", 1926651, 1731760742], ["014 - Benjamin Blümchen.mp3", 1916614, 1731760743], ["015 - Biene Maja.mp3", 2432360, 1731760744], ["016 - Bruder Jakob.mp3", 1269184, 1731760743], ["017 - Old Mc Donald hat ein Haus.mp3", 1564709, 1731760744], ["018 - Pinocchio.mp3", 2973198, 1731760745], ["019 - Polonäse Blankenese.mp3", 2647210, 1731760745], ["020 - Ringel, Ringel Reihe.mp3", 1719343, 1731760745], ["021 - Guten Abend gute Nacht.mp3", 2584103, 1731760746], ["022 - Guter Mond du gehst so stille.mp3", 2085075, 1731760746], ["023 - Hände waschen.mp3", 2290260, 1731760747], ["024 - Wie schön dass Du geboren bist.mp3", 2527278, 1731760747], ["025 - Zehn kleine Gartenzwerge.mp3", 3493169, 1731760748], ["026 - Widele wedele hinterm Städtele.mp3", 2510559, 1731760748], ["027 - Wir gehen jetzt im Kreise.mp3", 1570558, 1731760748], ["028 - Brüderchen komm tanz mit mir.mp3", 2890062, 1731760749], ["029 - Alle Leut´.mp3", 2248877, 1731760749], ["030 - Alles Gute (zum Geburtstag).mp3", 1430127, 1731760749], ["031 - Auf der Mauer af der Lauer.mp3", 2985353, 1731760751], ["032 - Das Wandern ist des Müllers Lust.mp3", 2243488, 1731760751], ["033 - Auf der grünen Wiese (das Karussell Lied).mp3", 2219682, 1731760752], ["034 - Backe backe Kuchen.mp3", 1743162, 1731760752], ["035 - Bunt sind schon die Wälder.mp3", 1845996, 1731760752], ["036 - Cowboys und Indianer.mp3", 2401870, 1731760753], ["037 - Auf unsrer Wiese gehet was.mp3", 2030734, 1731760753], ["038 - Hänschen Klein.mp3", 2352539, 1731760753], ["039 - Das ABC Lied.mp3", 2044499, 1731760754], ["040 - Das Lied der Schlümpfe.mp3", 2110975, 1731760754], ["041 - Das rote Pferd.mp3", 2469568, 1731760754], ["042 - Die Jahresuhr.mp3", 2109285, 1731760755], ["043 - Der Mond ist aufgegangen.mp3", 2806044, 1731760756], ["044 - Der Pirat.mp3", 2485022, 1731760756], ["045 - Der Sandmann ist da.mp3", 1651212, 1731760756], ["046 - Die Affen rasen durch den Wald.mp3", 3281693, 1731760758], ["047 - Auf der Schwäb'schen Eisenbahn.mp3", 2288625, 1731760757], ["048 - Dornröschen.mp3", 2593277, 1731760758], ["049 - Die Tiroler sind lustig.mp3", 2314941, 1731760758], ["050 - Ein Mann der sich Kolumbus nannt.mp3", 3187656, 1731760759], ["051 - Ein Männlein steht im Walde.mp3", 1866896, 1731760759], ["052 - Ein Vogel wollte Hochzeit machen.mp3", 3586390, 1731760760], ["053 - Eine Seefahrt die ist lustig.mp3", 2310770, 1731760760], ["054 - Ententanz.mp3", 1883579, 1731760760], ["055 - Es klappert die Mühle.mp3", 2141066, 1731760761], ["056 - Es schneit.mp3", 2247205, 1731760762], ["057 - Es tanzt ein Bi Ba-Butzeman.mp3", 1982671, 1731760761], ["058 - Heidi.mp3", 2526392, 1731760762], ["059 - Heute kann es regnen.mp3", 2905511, 1731760763], ["060 - Hey Pippi Langstrumpf.mp3", 2138139, 1731760764], ["061 - Hier kommt die Maus.mp3", 2034482, 1731760763], ["062 - Hopp, hopp, hopp, Pferdchen lauf galopp.mp3", 2048732, 1731760764], ["063 - Hänsel und Gretel.mp3", 2432375, 1731760765], ["064 - Häschen in der Grube.mp3", 2432380, 1731760765], ["065 - Horch was kommt von draussen rein.mp3", 2124788, 1731760765], ["066 - Hurrah, hurrah der Pumuckl ist da.mp3", 2229697, 1731760766], ["067 - Ich geh mit meiner Laterne.mp3", 1676305, 1731760766], ["068 - Ihr Kinderlein kommet.mp3", 2823174, 1731760766], ["069 - Im Frühtau zu Berge.mp3", 1961339, 1731760767], ["070 - In einem kleinen Apfel.mp3", 1559684, 1731760767], ["071 - Jetzt fahrn ́wir übern ́ See.mp3", 2583280, 1731760768], ["072 - Kuckuck, Kuckuck.mp3", 1919537, 1731760768], ["073 - Lasst uns Froh und Munter sein.mp3", 2247663, 1731760769], ["074 - Laterne, Laterne, Sonne, Mond und Sterne.mp3", 2295331, 1731760769], ["075 - Mein Hut der hat drei Ecken.mp3", 2107222, 1731760769], ["076 - Meine Oma fährt im Hühnerstall Motorrad.mp3", 2347995, 1731760770], ["077 - Oh Du lieber Augustin.mp3", 1661665, 1731760770], ["078 - Oh Suzanna.mp3", 2111369, 1731760770], ["079 - Rommel Bommel.mp3", 2299456, 1731760771], ["080 - Rucki Zucki.mp3", 2176990, 1731760772], ["081 - Sankt Martin ritt durch Schnee und Wind.mp3", 2029506, 1731760771], ["082 - Schlaf Kindlein schlaf.mp3", 2679816, 1731760772], ["083 - Schnappi.mp3", 2152325, 1731760773], ["084 - Schneeflöckchen Weißröckchen.mp3", 2267720, 1731760773], ["085 - Summ summ summ.mp3", 1805430, 1731760773], ["086 - Sur le Pont d ́Avignon.mp3", 2169489, 1731760774], ["087 - Theo Theo.mp3", 3495229, 1731760775], ["088 - Trarira der Sommer der ist da.mp3", 2121855, 1731760775], ["089 - Tschu Tschu wa.mp3", 3277483, 1731760775], ["090 - Turaluraluralu ich mach bubu, was machst Du.mp3", 2661051, 1731760777], ["091 - Vielen Dank für die Blumen.mp3", 3002907, 1731760777], ["092 - Von den blauen Bergen kommen wir (yuppie ya ya).mp3", 2451661, 1731760777], ["093 - Taler, Taler du musst wandern.mp3", 2180788, 1731760778], ["094 - Was müssen das für Bäume sein.mp3", 2353405, 1731760778], ["095 - Wer hat die schönsten Schäfchen.mp3", 2177030, 1731760778], ["096 - Wer will fleissige Handwerker sehn.mp3", 2287795, 1731760779], ["097 - Wickie.mp3", 1877721, 1731760780], ["098 - Zehn kleine Zappelfinger.mp3", 1518728, 1731760780], ["099 - Zeigt her Eure Füße.mp3", 1721849, 1731760780], ["100 - Alle Vöglein sind schon da.mp3", 3081068, 1731760781], ["101 - Alle meine Entchen.mp3", 1339833, 1731760781]]}, {"id": "623d55cf9af0", "section": "Musik", "kind": "music", "title": "Hurra Kinderlieder 1", "artist": "Hurra Kinderlieder", "series": null, "figure": null, "colors": ["#b3e178", "#8a4c4b", "#fb9600"], "folder": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1", "cover": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/cover.jpg", "tracks": [{"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/01 - Rums die Wolke.mp3", "title": "Rums die Wolke", "duration": 96.39183673469388, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/02 - Fauchi der kleine Tiger.mp3", "title": "Fauchi der kleine Tiger", "duration": 94.25333333333333, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/03 - Wenn sich die Igel küssen.mp3", "title": "Wenn sich die Igel küssen", "duration": 90.90612244897959, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/04 - Schade Marmelade.mp3", "title": "Schade Marmelade", "duration": 121.50666666666666, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/05 - Die Maus ist allein zu Haus.mp3", "title": "Die Maus ist allein zu Haus", "duration": 109.57333333333334, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/06 - Das Apfel Lied.mp3", "title": "Das Apfel Lied", "duration": 97.41061224489796, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/07 - Ferien auf dem Bauernhof.mp3", "title": "Ferien auf dem Bauernhof", "duration": 131.8138775510204, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/08 - Unser Haustier Funki.mp3", "title": "Unser Haustier Funki", "duration": 126.72, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/09 - Eier Raten Lied.mp3", "title": "Eier Raten Lied", "duration": 117.42040816326531, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/10 - Der Eisbär ging zum Eismann.mp3", "title": "Der Eisbär ging zum Eismann", "duration": 163.63102040816327, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/11 - Kika-Chamäleon.mp3", "title": "Kika-Chamäleon", "duration": 148.06204081632654, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/12 - Guten Appetit.mp3", "title": "Guten Appetit", "duration": 178.52081632653062, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/13 - Der kleine Tausendfüßler.mp3", "title": "Der kleine Tausendfüßler", "duration": 103.20979591836735, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/14 - Ich bin Ben die Robbe.mp3", "title": "Ich bin Ben die Robbe", "duration": 119.40571428571428, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/15 - Der Pavianpopo.mp3", "title": "Der Pavianpopo", "duration": 171.2, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/16 - Kleines freches Stachelschwein.mp3", "title": "Kleines freches Stachelschwein", "duration": 117.05469387755102, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/17 - Ich bin ein kleiner Pinguin.mp3", "title": "Ich bin ein kleiner Pinguin", "duration": 89.96571428571428, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/18 - Die kleine Spinne.mp3", "title": "Die kleine Spinne", "duration": 125.70122448979592, "analysis": null}, {"path": "/home/martin/Music/Musik/Hurra Kinderlieder - Hurra Kinderlieder 1/19 - Der verflixte Ball.mp3", "title": "Der verflixte Ball", "duration": 109.63591836734693, "analysis": null}], "files": [["01 - Rums die Wolke.mp3", 1768793, 1731760782], ["02 - Fauchi der kleine Tiger.mp3", 1735374, 1731760782], ["03 - Wenn sich die Igel küssen.mp3", 1681043, 1731760782], ["04 - Schade Marmelade.mp3", 2171292, 1731760784], ["05 - Die Maus ist allein zu Haus.mp3", 1980306, 1731760784], ["06 - Das Apfel Lied.mp3", 1785093, 1731760783], ["07 - Ferien auf dem Bauernhof.mp3", 2335567, 1731760784], ["08 - Unser Haustier Funki.mp3", 2254057, 1731760785], ["09 - Eier Raten Lied.mp3", 2105253, 1731760785], ["10 - Der Eisbär ging zum Eismann.mp3", 2844649, 1731760785], ["11 - Kika-Chamäleon.mp3", 2595519, 1731760786], ["12 - Guten Appetit.mp3", 3082857, 1731760786], ["13 - Der kleine Tausendfüßler.mp3", 1877903, 1731760786], ["14 - Ich bin Ben die Robbe.mp3", 2137032, 1731760787], ["15 - Der Pavianpopo.mp3", 2966249, 1731760787], ["16 - Kleines freches Stachelschwein.mp3", 2099433, 1731760788], ["17 - Ich bin ein kleiner Pinguin.mp3", 1666002, 1731760788], ["18 - Die kleine Spinne.mp3", 2237752, 1731760788], ["19 - Der verflixte Ball.mp3", 1980709, 1731760789]]}, {"id": "33ecb3344e53", "section": "Musik", "kind": "music", "title": "Kinderparty Lieder", "artist": "Kinderparty", "series": null, "figure": null, "colors": ["#d484a7", "#4e5f1f", "#82e6ad"], "folder": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder", "cover": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/cover.jpg", "tracks": [{"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/01 - So ein schöner Tag (Fliegerlied).mp3", "title": "So ein schöner Tag (Fliegerlied)", "duration": 200.51591836734693, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/02 - Meine Oma fährt im Hühnerstall Motorrad.mp3", "title": "Meine Oma fährt im Hühnerstall Motorrad", "duration": 197.4073469387755, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/03 - Das kleine Küken piept.mp3", "title": "Das kleine Küken piept", "duration": 160.10448979591837, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/04 - Ich bin dein Gummibär.mp3", "title": "Ich bin dein Gummibär", "duration": 190.45877551020408, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/05 - Der Alien-Tanz.mp3", "title": "Der Alien-Tanz", "duration": 179.98367346938775, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/06 - Tschu Tschu Wa.mp3", "title": "Tschu Tschu Wa", "duration": 171.8595918367347, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/07 - Das Lied über mich.mp3", "title": "Das Lied über mich", "duration": 225.82857142857142, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/08 - Das rote Pferd.mp3", "title": "Das rote Pferd", "duration": 105.48244897959184, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/09 - Das Flummilied.mp3", "title": "Das Flummilied", "duration": 183.11836734693878, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/10 - Rucki Zucki.mp3", "title": "Rucki Zucki", "duration": 149.49877551020407, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/11 - A-E-I-O-U - Komm, lass uns tanzen.mp3", "title": "A-E-I-O-U - Komm, lass uns tanzen", "duration": 195.94448979591837, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/12 - Hörst du die Regenwürmer husten_.mp3", "title": "Hörst du die Regenwürmer husten?", "duration": 201.11673469387756, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/13 - Wenn der Elefant in die Disco geht.mp3", "title": "Wenn der Elefant in die Disco geht", "duration": 180.27102040816325, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/14 - Feuerwehr Tatü Tata.mp3", "title": "Feuerwehr Tatü Tata", "duration": 231.57551020408164, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/15 - Dreh den Propeller.mp3", "title": "Dreh den Propeller", "duration": 181.4726530612245, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/16 - Cowboy & Indianer.mp3", "title": "Cowboy & Indianer", "duration": 189.98857142857142, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/17 - Alle Kinder lernen lesen.mp3", "title": "Alle Kinder lernen lesen", "duration": 121.52163265306122, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/18 - Ich bin ein Einhorn.mp3", "title": "Ich bin ein Einhorn", "duration": 135.52326530612245, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/19 - Das singende Känguru.mp3", "title": "Das singende Känguru", "duration": 205.8187755102041, "analysis": null}, {"path": "/home/martin/Music/Musik/Kinderparty - Kinderparty Lieder/20 - Der Hamstertanz.mp3", "title": "Der Hamstertanz", "duration": 129.46285714285713, "analysis": null}], "files": [["01 - So ein schöner Tag (Fliegerlied).mp3", 3365422, 1640034273], ["02 - Meine Oma fährt im Hühnerstall Motorrad.mp3", 3315775, 1640034274], ["03 - Das kleine Küken piept.mp3", 2718819, 1640034273], ["04 - Ich bin dein Gummibär.mp3", 3204562, 1640034275], ["05 - Der Alien-Tanz.mp3", 3036874, 1640034275], ["06 - Tschu Tschu Wa.mp3", 2906885, 1640034276], ["07 - Das Lied über mich.mp3", 3770473, 1640034278], ["08 - Das rote Pferd.mp3", 1844927, 1640034277], ["09 - Das Flummilied.mp3", 3087101, 1640034278], ["10 - Rucki Zucki.mp3", 2549184, 1640034279], ["11 - A-E-I-O-U - Komm, lass uns tanzen.mp3", 3292359, 1640034280], ["12 - Hörst du die Regenwürmer husten_.mp3", 3375113, 1640034281], ["13 - Wenn der Elefant in die Disco geht.mp3", 3041586, 1640034281], ["14 - Feuerwehr Tatü Tata.mp3", 3862428, 1640034282], ["15 - Dreh den Propeller.mp3", 3060780, 1640034283], ["16 - Cowboy & Indianer.mp3", 3197033, 1640034283], ["17 - Alle Kinder lernen lesen.mp3", 2101576, 1640034284], ["18 - Ich bin ein Einhorn.mp3", 2325592, 1640034285], ["19 - Das singende Känguru.mp3", 3450322, 1640034286], ["20 - Der Hamstertanz.mp3", 2228617, 1640034286]]}, {"id": "7b8e69e48fa9", "section": "Hörbücher", "kind": "book", "title": "Benjamin Blümchen Liederzoo: Fußballfieber", "artist": "Benjamin Blümchen", "series": "Benjamin Blümchen", "figure": null, "colors": ["#5d9c3e", "#85c3d1", "#ffec00"], "folder": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber", "cover": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/01 - Benjamin, der Fußballstar.mp3", "title": "Benjamin, der Fußballstar", "duration": 202.55346938775511, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/02 - Trainer Benjamin.mp3", "title": "Trainer Benjamin", "duration": 179.8269387755102, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/03 - Wir haben gewonnen!.mp3", "title": "Wir haben gewonnen!", "duration": 202.44897959183675, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/04 - Stella vor, noch ein Tor!.mp3", "title": "Stella vor, noch ein Tor!", "duration": 199.26204081632653, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/05 - Zoofußball.mp3", "title": "Zoofußball", "duration": 169.3257142857143, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/06 - Der Fußball, der ist rund!.mp3", "title": "Der Fußball, der ist rund!", "duration": 161.14938775510205, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/07 - Elf Freunde müsst ihr sein!.mp3", "title": "Elf Freunde müsst ihr sein!", "duration": 180.03591836734694, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/08 - Gulliver, der Fußballfan.mp3", "title": "Gulliver, der Fußballfan", "duration": 195.31755102040816, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/09 - Ich will einen Fußball.mp3", "title": "Ich will einen Fußball", "duration": 186.25306122448978, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/10 - Mach dir nichts draus.mp3", "title": "Mach dir nichts draus", "duration": 179.93142857142857, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/11 - Wir halten zusammen.mp3", "title": "Wir halten zusammen", "duration": 184.32, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Fußballfieber/12 - Der Pokal.mp3", "title": "Der Pokal", "duration": 170.16163265306122, "analysis": null}], "files": [["01 - Benjamin, der Fußballstar.mp3", 3457038, 1787746587], ["02 - Trainer Benjamin.mp3", 3093396, 1787746589], ["03 - Wir haben gewonnen!.mp3", 3455354, 1787746593], ["04 - Stella vor, noch ein Tor!.mp3", 3404375, 1787746592], ["05 - Zoofußball.mp3", 2925364, 1787746592], ["06 - Der Fußball, der ist rund!.mp3", 2794575, 1787746589], ["07 - Elf Freunde müsst ihr sein!.mp3", 3096761, 1787746589], ["08 - Gulliver, der Fußballfan.mp3", 3341261, 1787746592], ["09 - Ich will einen Fußball.mp3", 3196226, 1787746587], ["10 - Mach dir nichts draus.mp3", 3095079, 1787746592], ["11 - Wir halten zusammen.mp3", 3165293, 1787746589], ["12 - Der Pokal.mp3", 2938739, 1787746589]]}, {"id": "4af1e7a67db4", "section": "Hörbücher", "kind": "book", "title": "Benjamin Blümchen Liederzoo: Geburtstagsparty", "artist": "Benjamin Blümchen", "series": "Benjamin Blümchen", "figure": null, "colors": ["#40ab1a", "#1f2473", "#dc5f9a"], "folder": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty", "cover": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/01 - Heut' ist dein Geburtstag.mp3", "title": "Heut' ist dein Geburtstag", "duration": 183.04, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/02 - Das ist cool!.mp3", "title": "Das ist cool!", "duration": 186.07020408163265, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/03 - Spiele spielen.mp3", "title": "Spiele spielen", "duration": 187.97714285714287, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/04 - Hexy Birthday.mp3", "title": "Hexy Birthday", "duration": 181.1069387755102, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/05 - Überall Konfetti.mp3", "title": "Überall Konfetti", "duration": 179.80081632653062, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/06 - Geburtstagsküsschen.mp3", "title": "Geburtstagsküsschen", "duration": 199.54938775510203, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/07 - Gullis Geburtstags-Rock.mp3", "title": "Gullis Geburtstags-Rock", "duration": 152.58122448979591, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/08 - Schlürf-Schmatz-Schleck.mp3", "title": "Schlürf-Schmatz-Schleck", "duration": 191.6843537414966, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/09 - Elf kleine Kängurus.mp3", "title": "Elf kleine Kängurus", "duration": 191.45142857142858, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/10 - Die Ramba-Zamba-Band.mp3", "title": "Die Ramba-Zamba-Band", "duration": 187.06285714285715, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/11 - Der Elefantentanz.mp3", "title": "Der Elefantentanz", "duration": 177.08426303854876, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Blümchen Liederzoo_ Geburtstagsparty/12 - Hallo, kleiner Luftballon.mp3", "title": "Hallo, kleiner Luftballon", "duration": 177.71102040816328, "analysis": null}], "files": [["01 - Heut' ist dein Geburtstag.mp3", 3131589, 1787746957], ["02 - Das ist cool!.mp3", 3180048, 1787746958], ["03 - Spiele spielen.mp3", 3210561, 1787746962], ["04 - Hexy Birthday.mp3", 3100636, 1787746959], ["05 - Überall Konfetti.mp3", 3079743, 1787746959], ["06 - Geburtstagsküsschen.mp3", 3395727, 1787746959], ["07 - Gullis Geburtstags-Rock.mp3", 2644243, 1787746961], ["08 - Schlürf-Schmatz-Schleck.mp3", 3270333, 1787746962], ["09 - Elf kleine Kängurus.mp3", 3266159, 1787746959], ["10 - Die Ramba-Zamba-Band.mp3", 3195946, 1787746958], ["11 - Der Elefantentanz.mp3", 3037102, 1787746961], ["12 - Hallo, kleiner Luftballon.mp3", 3046313, 1787746959]]}, {"id": "d86b7beb4199", "section": "Hörbücher", "kind": "book", "title": "Benjamin Minis - Folge 1: Hallo, Lea!", "artist": "Benjamin Blümchen", "series": "Benjamin Blümchen", "figure": null, "colors": ["#abe0ff", "#f10000", "#eb6365"], "folder": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!", "cover": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/01 - Benjamin Blümchen Lied.mp3", "title": "Benjamin Blümchen Lied", "duration": 49.44979591836735, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/02 - Kapitel 01_ Hallo, Lea! (Folge 1).mp3", "title": "Kapitel 01: Hallo, Lea! (Folge 1)", "duration": 96.2143537414966, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/03 - Kapitel 02_ Hallo, Lea! (Folge 1).mp3", "title": "Kapitel 02: Hallo, Lea! (Folge 1)", "duration": 98.09968253968253, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/04 - Kapitel 03_ Hallo, Lea! (Folge 1).mp3", "title": "Kapitel 03: Hallo, Lea! (Folge 1)", "duration": 94.49049886621316, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/05 - Kapitel 04_ Hallo, Lea! (Folge 1).mp3", "title": "Kapitel 04: Hallo, Lea! (Folge 1)", "duration": 92.40981859410431, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/06 - Kapitel 05_ Hallo, Lea! (Folge 1).mp3", "title": "Kapitel 05: Hallo, Lea! (Folge 1)", "duration": 97.87691609977324, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/07 - Kapitel 06_ Hallo, Lea! (Folge 1).mp3", "title": "Kapitel 06: Hallo, Lea! (Folge 1)", "duration": 94.33095238095238, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/08 - Kapitel 07_ Hallo, Lea! (Folge 1).mp3", "title": "Kapitel 07: Hallo, Lea! (Folge 1)", "duration": 79.17031746031746, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/09 - Kapitel 01_ Ein Tier mit Fantasie (Folge 1).mp3", "title": "Kapitel 01: Ein Tier mit Fantasie (Folge 1)", "duration": 97.25757369614513, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/10 - Kapitel 02_ Ein Tier mit Fantasie (Folge 1).mp3", "title": "Kapitel 02: Ein Tier mit Fantasie (Folge 1)", "duration": 105.92263038548752, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/11 - Kapitel 03_ Ein Tier mit Fantasie (Folge 1).mp3", "title": "Kapitel 03: Ein Tier mit Fantasie (Folge 1)", "duration": 94.8585941043084, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/12 - Kapitel 04_ Ein Tier mit Fantasie (Folge 1).mp3", "title": "Kapitel 04: Ein Tier mit Fantasie (Folge 1)", "duration": 93.56632653061224, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/13 - Kapitel 05_ Ein Tier mit Fantasie (Folge 1).mp3", "title": "Kapitel 05: Ein Tier mit Fantasie (Folge 1)", "duration": 93.8339455782313, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/14 - Kapitel 06_ Ein Tier mit Fantasie (Folge 1).mp3", "title": "Kapitel 06: Ein Tier mit Fantasie (Folge 1)", "duration": 85.58671201814059, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/15 - Kapitel 07_ Ein Tier mit Fantasie (Folge 1).mp3", "title": "Kapitel 07: Ein Tier mit Fantasie (Folge 1)", "duration": 78.41818594104308, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/16 - Kapitel 01_ Keine Angst vorm Wasser (Folge 1).mp3", "title": "Kapitel 01: Keine Angst vorm Wasser (Folge 1)", "duration": 125.71666666666667, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/17 - Kapitel 02_ Keine Angst vorm Wasser (Folge 1).mp3", "title": "Kapitel 02: Keine Angst vorm Wasser (Folge 1)", "duration": 95.26514739229025, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/18 - Kapitel 03_ Keine Angst vorm Wasser (Folge 1).mp3", "title": "Kapitel 03: Keine Angst vorm Wasser (Folge 1)", "duration": 89.56126984126985, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/19 - Kapitel 04_ Keine Angst vorm Wasser (Folge 1).mp3", "title": "Kapitel 04: Keine Angst vorm Wasser (Folge 1)", "duration": 99.09485260770975, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/20 - Kapitel 05_ Keine Angst vorm Wasser (Folge 1).mp3", "title": "Kapitel 05: Keine Angst vorm Wasser (Folge 1)", "duration": 95.65301587301587, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/21 - Kapitel 06_ Keine Angst vorm Wasser (Folge 1).mp3", "title": "Kapitel 06: Keine Angst vorm Wasser (Folge 1)", "duration": 101.61920634920635, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/22 - Kapitel 01_ Der verflixte Schluckauf (Folge 1).mp3", "title": "Kapitel 01: Der verflixte Schluckauf (Folge 1)", "duration": 83.12639455782313, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/23 - Kapitel 02_ Der verflixte Schluckauf (Folge 1).mp3", "title": "Kapitel 02: Der verflixte Schluckauf (Folge 1)", "duration": 98.26566893424037, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/24 - Kapitel 03_ Der verflixte Schluckauf (Folge 1).mp3", "title": "Kapitel 03: Der verflixte Schluckauf (Folge 1)", "duration": 99.42269841269841, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/25 - Kapitel 04_ Der verflixte Schluckauf (Folge 1).mp3", "title": "Kapitel 04: Der verflixte Schluckauf (Folge 1)", "duration": 92.19131519274376, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/26 - Kapitel 05_ Der verflixte Schluckauf (Folge 1).mp3", "title": "Kapitel 05: Der verflixte Schluckauf (Folge 1)", "duration": 93.3515873015873, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/27 - Kapitel 06_ Der verflixte Schluckauf (Folge 1).mp3", "title": "Kapitel 06: Der verflixte Schluckauf (Folge 1)", "duration": 94.12333333333333, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 1_ Hallo, Lea!/28 - Kapitel 07_ Der verflixte Schluckauf (Folge 1).mp3", "title": "Kapitel 07: Der verflixte Schluckauf (Folge 1)", "duration": 66.59814058956916, "analysis": null}], "files": [["01 - Benjamin Blümchen Lied.mp3", 918207, 1787747094], ["02 - Kapitel 01_ Hallo, Lea! (Folge 1).mp3", 1667212, 1787747090], ["03 - Kapitel 02_ Hallo, Lea! (Folge 1).mp3", 1697305, 1787747090], ["04 - Kapitel 03_ Hallo, Lea! (Folge 1).mp3", 1639627, 1787747090], ["05 - Kapitel 04_ Hallo, Lea! (Folge 1).mp3", 1606190, 1787747092], ["06 - Kapitel 05_ Hallo, Lea! (Folge 1).mp3", 1693544, 1787747090], ["07 - Kapitel 06_ Hallo, Lea! (Folge 1).mp3", 1637119, 1787747095], ["08 - Kapitel 07_ Hallo, Lea! (Folge 1).mp3", 1394285, 1787747092], ["09 - Kapitel 01_ Ein Tier mit Fantasie (Folge 1).mp3", 1683950, 1787747091], ["10 - Kapitel 02_ Ein Tier mit Fantasie (Folge 1).mp3", 1822298, 1787747091], ["11 - Kapitel 03_ Ein Tier mit Fantasie (Folge 1).mp3", 1645500, 1787747091], ["12 - Kapitel 04_ Ein Tier mit Fantasie (Folge 1).mp3", 1624602, 1787747096], ["13 - Kapitel 05_ Ein Tier mit Fantasie (Folge 1).mp3", 1629200, 1787747096], ["14 - Kapitel 06_ Ein Tier mit Fantasie (Folge 1).mp3", 1497125, 1787747096], ["15 - Kapitel 07_ Ein Tier mit Fantasie (Folge 1).mp3", 1382186, 1787747092], ["16 - Kapitel 01_ Keine Angst vorm Wasser (Folge 1).mp3", 2139115, 1787747095], ["17 - Kapitel 02_ Keine Angst vorm Wasser (Folge 1).mp3", 1651774, 1787747093], ["18 - Kapitel 03_ Keine Angst vorm Wasser (Folge 1).mp3", 1560659, 1787747092], ["19 - Kapitel 04_ Keine Angst vorm Wasser (Folge 1).mp3", 1713214, 1787747094], ["20 - Kapitel 05_ Keine Angst vorm Wasser (Folge 1).mp3", 1658043, 1787747095], ["21 - Kapitel 06_ Keine Angst vorm Wasser (Folge 1).mp3", 1753757, 1787747093], ["22 - Kapitel 01_ Der verflixte Schluckauf (Folge 1).mp3", 1457843, 1787747095], ["23 - Kapitel 02_ Der verflixte Schluckauf (Folge 1).mp3", 1699841, 1787747095], ["24 - Kapitel 03_ Der verflixte Schluckauf (Folge 1).mp3", 1718649, 1787747095], ["25 - Kapitel 04_ Der verflixte Schluckauf (Folge 1).mp3", 1602874, 1787747096], ["26 - Kapitel 05_ Der verflixte Schluckauf (Folge 1).mp3", 1621265, 1787747096], ["27 - Kapitel 06_ Der verflixte Schluckauf (Folge 1).mp3", 1633803, 1787747096], ["28 - Kapitel 07_ Der verflixte Schluckauf (Folge 1).mp3", 1193274, 1787747096]]}, {"id": "428a1b45504d", "section": "Hörbücher", "kind": "book", "title": "Benjamin Minis - Folge 2: Keine Angst im Dunkeln", "artist": "Benjamin Blümchen", "series": "Benjamin Blümchen", "figure": null, "colors": ["#c187a6", "#f2a011", "#fff77a"], "folder": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln", "cover": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/01 - Benjamin Blümchen Lied.mp3", "title": "Benjamin Blümchen Lied", "duration": 49.44979591836735, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/02 - Kapitel 01_ Keine Angst im Dunkeln (Folge 2).mp3", "title": "Kapitel 01: Keine Angst im Dunkeln (Folge 2)", "duration": 95.3552380952381, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/03 - Kapitel 02_ Keine Angst im Dunkeln (Folge 2).mp3", "title": "Kapitel 02: Keine Angst im Dunkeln (Folge 2)", "duration": 94.8418820861678, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/04 - Kapitel 03_ Keine Angst im Dunkeln (Folge 2).mp3", "title": "Kapitel 03: Keine Angst im Dunkeln (Folge 2)", "duration": 95.23829931972789, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/05 - Kapitel 04_ Keine Angst im Dunkeln (Folge 2).mp3", "title": "Kapitel 04: Keine Angst im Dunkeln (Folge 2)", "duration": 96.81757369614512, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/06 - Kapitel 05_ Keine Angst im Dunkeln (Folge 2).mp3", "title": "Kapitel 05: Keine Angst im Dunkeln (Folge 2)", "duration": 93.79970521541951, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/07 - Kapitel 06_ Keine Angst im Dunkeln (Folge 2).mp3", "title": "Kapitel 06: Keine Angst im Dunkeln (Folge 2)", "duration": 140.4189342403628, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/08 - Kapitel 01_ Der erste Wackelzahn (Folge 2).mp3", "title": "Kapitel 01: Der erste Wackelzahn (Folge 2)", "duration": 96.4239455782313, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/09 - Kapitel 02_ Der erste Wackelzahn (Folge 2).mp3", "title": "Kapitel 02: Der erste Wackelzahn (Folge 2)", "duration": 95.3514739229025, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/10 - Kapitel 03_ Der erste Wackelzahn (Folge 2).mp3", "title": "Kapitel 03: Der erste Wackelzahn (Folge 2)", "duration": 99.03401360544218, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/11 - Kapitel 04_ Der erste Wackelzahn (Folge 2).mp3", "title": "Kapitel 04: Der erste Wackelzahn (Folge 2)", "duration": 98.92743764172336, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/12 - Kapitel 05_ Der erste Wackelzahn (Folge 2).mp3", "title": "Kapitel 05: Der erste Wackelzahn (Folge 2)", "duration": 91.07709750566893, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/13 - Kapitel 06_ Der erste Wackelzahn (Folge 2).mp3", "title": "Kapitel 06: Der erste Wackelzahn (Folge 2)", "duration": 166.23668934240362, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/14 - Kapitel 01_ Die eierlegende Wollmilchsau (Folge 2).mp3", "title": "Kapitel 01: Die eierlegende Wollmilchsau (Folge 2)", "duration": 78.58907029478458, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/15 - Kapitel 02_ Die eierlegende Wollmilchsau (Folge 2).mp3", "title": "Kapitel 02: Die eierlegende Wollmilchsau (Folge 2)", "duration": 90.42403628117914, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/16 - Kapitel 03_ Die eierlegende Wollmilchsau (Folge 2).mp3", "title": "Kapitel 03: Die eierlegende Wollmilchsau (Folge 2)", "duration": 96.29222222222222, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/17 - Kapitel 04_ Die eierlegende Wollmilchsau (Folge 2).mp3", "title": "Kapitel 04: Die eierlegende Wollmilchsau (Folge 2)", "duration": 89.32809523809524, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/18 - Kapitel 05_ Die eierlegende Wollmilchsau (Folge 2).mp3", "title": "Kapitel 05: Die eierlegende Wollmilchsau (Folge 2)", "duration": 101.29251700680273, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/19 - Kapitel 06_ Die eierlegende Wollmilchsau (Folge 2).mp3", "title": "Kapitel 06: Die eierlegende Wollmilchsau (Folge 2)", "duration": 85.53410430839003, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/20 - Kapitel 07_ Die eierlegende Wollmilchsau (Folge 2).mp3", "title": "Kapitel 07: Die eierlegende Wollmilchsau (Folge 2)", "duration": 109.07027210884354, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/21 - Kapitel 01_ Das Loch in der Hose (Folge 2).mp3", "title": "Kapitel 01: Das Loch in der Hose (Folge 2)", "duration": 86.06145124716554, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/22 - Kapitel 02_ Das Loch in der Hose (Folge 2).mp3", "title": "Kapitel 02: Das Loch in der Hose (Folge 2)", "duration": 97.54285714285714, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/23 - Kapitel 03_ Das Loch in der Hose (Folge 2).mp3", "title": "Kapitel 03: Das Loch in der Hose (Folge 2)", "duration": 93.68501133786847, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/24 - Kapitel 04_ Das Loch in der Hose (Folge 2).mp3", "title": "Kapitel 04: Das Loch in der Hose (Folge 2)", "duration": 96.21390022675737, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/25 - Kapitel 05_ Das Loch in der Hose (Folge 2).mp3", "title": "Kapitel 05: Das Loch in der Hose (Folge 2)", "duration": 96.3171201814059, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 2_ Keine Angst im Dunkeln/26 - Kapitel 06_ Das Loch in der Hose (Folge 2).mp3", "title": "Kapitel 06: Das Loch in der Hose (Folge 2)", "duration": 129.35988662131518, "analysis": null}], "files": [["01 - Benjamin Blümchen Lied.mp3", 914847, 1787746532], ["02 - Kapitel 01_ Keine Angst im Dunkeln (Folge 2).mp3", 1650081, 1787746533], ["03 - Kapitel 02_ Keine Angst im Dunkeln (Folge 2).mp3", 1641722, 1787746532], ["04 - Kapitel 03_ Keine Angst im Dunkeln (Folge 2).mp3", 1647992, 1787746533], ["05 - Kapitel 04_ Keine Angst im Dunkeln (Folge 2).mp3", 1673487, 1787746534], ["06 - Kapitel 05_ Keine Angst im Dunkeln (Folge 2).mp3", 1625004, 1787746533], ["07 - Kapitel 06_ Keine Angst im Dunkeln (Folge 2).mp3", 2371062, 1787746538], ["08 - Kapitel 01_ Der erste Wackelzahn (Folge 2).mp3", 1667214, 1787746533], ["09 - Kapitel 02_ Der erste Wackelzahn (Folge 2).mp3", 1650077, 1787746533], ["10 - Kapitel 03_ Der erste Wackelzahn (Folge 2).mp3", 1709012, 1787746534], ["11 - Kapitel 04_ Der erste Wackelzahn (Folge 2).mp3", 1707340, 1787746538], ["12 - Kapitel 05_ Der erste Wackelzahn (Folge 2).mp3", 1581534, 1787746534], ["13 - Kapitel 06_ Der erste Wackelzahn (Folge 2).mp3", 2784004, 1787746538], ["14 - Kapitel 01_ Die eierlegende Wollmilchsau (Folge 2).mp3", 1381766, 1787746535], ["15 - Kapitel 02_ Die eierlegende Wollmilchsau (Folge 2).mp3", 1571101, 1787746534], ["16 - Kapitel 03_ Die eierlegende Wollmilchsau (Folge 2).mp3", 1665142, 1787746534], ["17 - Kapitel 04_ Die eierlegende Wollmilchsau (Folge 2).mp3", 1553547, 1787746535], ["18 - Kapitel 05_ Die eierlegende Wollmilchsau (Folge 2).mp3", 1744973, 1787746536], ["19 - Kapitel 06_ Die eierlegende Wollmilchsau (Folge 2).mp3", 1492943, 1787746538], ["20 - Kapitel 07_ Die eierlegende Wollmilchsau (Folge 2).mp3", 1869525, 1787746536], ["21 - Kapitel 01_ Das Loch in der Hose (Folge 2).mp3", 1501286, 1787746536], ["22 - Kapitel 02_ Das Loch in der Hose (Folge 2).mp3", 1685188, 1787746538], ["23 - Kapitel 03_ Das Loch in der Hose (Folge 2).mp3", 1623330, 1787746537], ["24 - Kapitel 04_ Das Loch in der Hose (Folge 2).mp3", 1663872, 1787746537], ["25 - Kapitel 05_ Das Loch in der Hose (Folge 2).mp3", 1665544, 1787746537], ["26 - Kapitel 06_ Das Loch in der Hose (Folge 2).mp3", 2194263, 1787746537]]}, {"id": "4061167a3a21", "section": "Hörbücher", "kind": "book", "title": "Benjamin Minis - Folge 3: Das erste Fahrrad", "artist": "Benjamin Blümchen", "series": "Benjamin Blümchen", "figure": null, "colors": ["#9dd1f1", "#e80009", "#5e9931"], "folder": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad", "cover": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/01 - Benjamin Blümchen Lied.mp3", "title": "Benjamin Blümchen Lied", "duration": 49.44979591836735, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/02 - Kapitel 01_ Das erste Fahrrad (Folge 3).mp3", "title": "Kapitel 01: Das erste Fahrrad (Folge 3)", "duration": 94.37746031746032, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/03 - Kapitel 02_ Das erste Fahrrad (Folge 3).mp3", "title": "Kapitel 02: Das erste Fahrrad (Folge 3)", "duration": 88.67247165532879, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/04 - Kapitel 03_ Das erste Fahrrad (Folge 3).mp3", "title": "Kapitel 03: Das erste Fahrrad (Folge 3)", "duration": 99.51142857142857, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/05 - Kapitel 04_ Das erste Fahrrad (Folge 3).mp3", "title": "Kapitel 04: Das erste Fahrrad (Folge 3)", "duration": 95.31950113378684, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/06 - Kapitel 05_ Das erste Fahrrad (Folge 3).mp3", "title": "Kapitel 05: Das erste Fahrrad (Folge 3)", "duration": 88.34362811791384, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/07 - Kapitel 06_ Das erste Fahrrad (Folge 3).mp3", "title": "Kapitel 06: Das erste Fahrrad (Folge 3)", "duration": 150.32668934240363, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/08 - Kapitel 01_ Lea ist krank (Folge 3).mp3", "title": "Kapitel 01: Lea ist krank (Folge 3)", "duration": 97.65902494331065, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/09 - Kapitel 02_ Lea ist krank (Folge 3).mp3", "title": "Kapitel 02: Lea ist krank (Folge 3)", "duration": 84.18215419501134, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/10 - Kapitel 03_ Lea ist krank (Folge 3).mp3", "title": "Kapitel 03: Lea ist krank (Folge 3)", "duration": 101.96358276643991, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/11 - Kapitel 04_ Lea ist krank (Folge 3).mp3", "title": "Kapitel 04: Lea ist krank (Folge 3)", "duration": 94.13390022675738, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/12 - Kapitel 05_ Lea ist krank (Folge 3).mp3", "title": "Kapitel 05: Lea ist krank (Folge 3)", "duration": 90.78877551020408, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/13 - Kapitel 06_ Lea ist krank (Folge 3).mp3", "title": "Kapitel 06: Lea ist krank (Folge 3)", "duration": 91.07204081632653, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/14 - Kapitel 07_ Lea ist krank (Folge 3).mp3", "title": "Kapitel 07: Lea ist krank (Folge 3)", "duration": 142.16489795918366, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/15 - Kapitel 01_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 01: Der Drachen im Baum (Folge 3)", "duration": 94.82224489795918, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/16 - Kapitel 02_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 02: Der Drachen im Baum (Folge 3)", "duration": 88.32092970521542, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/17 - Kapitel 03_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 03: Der Drachen im Baum (Folge 3)", "duration": 98.0520634920635, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/18 - Kapitel 04_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 04: Der Drachen im Baum (Folge 3)", "duration": 95.87269841269841, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/19 - Kapitel 05_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 05: Der Drachen im Baum (Folge 3)", "duration": 92.83730158730158, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/20 - Kapitel 06_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 06: Der Drachen im Baum (Folge 3)", "duration": 93.4920634920635, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/21 - Kapitel 07_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 07: Der Drachen im Baum (Folge 3)", "duration": 92.30913832199546, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/22 - Kapitel 08_ Der Drachen im Baum (Folge 3).mp3", "title": "Kapitel 08: Der Drachen im Baum (Folge 3)", "duration": 124.09732426303854, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/23 - Kapitel 01_ Müde gespielt (Folge 3).mp3", "title": "Kapitel 01: Müde gespielt (Folge 3)", "duration": 93.6, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/24 - Kapitel 02_ Müde gespielt (Folge 3).mp3", "title": "Kapitel 02: Müde gespielt (Folge 3)", "duration": 97.40272108843537, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/25 - Kapitel 03_ Müde gespielt (Folge 3).mp3", "title": "Kapitel 03: Müde gespielt (Folge 3)", "duration": 88.25850340136054, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/26 - Kapitel 04_ Müde gespielt (Folge 3).mp3", "title": "Kapitel 04: Müde gespielt (Folge 3)", "duration": 95.65170068027211, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/27 - Kapitel 05_ Müde gespielt (Folge 3).mp3", "title": "Kapitel 05: Müde gespielt (Folge 3)", "duration": 91.65442176870748, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/28 - Kapitel 06_ Müde gespielt (Folge 3).mp3", "title": "Kapitel 06: Müde gespielt (Folge 3)", "duration": 85.21632653061225, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Benjamin Blümchen - Benjamin Minis - Folge 3_ Das erste Fahrrad/29 - Kapitel 07_ Müde gespielt (Folge 3).mp3", "title": "Kapitel 07: Müde gespielt (Folge 3)", "duration": 98.68845804988662, "analysis": null}], "files": [["01 - Benjamin Blümchen Lied.mp3", 923459, 1787745948], ["02 - Kapitel 01_ Das erste Fahrrad (Folge 3).mp3", 1642801, 1787745950], ["03 - Kapitel 02_ Das erste Fahrrad (Folge 3).mp3", 1551686, 1787745948], ["04 - Kapitel 03_ Das erste Fahrrad (Folge 3).mp3", 1725139, 1787745945], ["05 - Kapitel 04_ Das erste Fahrrad (Folge 3).mp3", 1657848, 1787745946], ["06 - Kapitel 05_ Das erste Fahrrad (Folge 3).mp3", 1546252, 1787745945], ["07 - Kapitel 06_ Das erste Fahrrad (Folge 3).mp3", 2538071, 1787745950], ["08 - Kapitel 01_ Lea ist krank (Folge 3).mp3", 1695456, 1787745946], ["09 - Kapitel 02_ Lea ist krank (Folge 3).mp3", 1479789, 1787745948], ["10 - Kapitel 03_ Lea ist krank (Folge 3).mp3", 1764422, 1787745950], ["11 - Kapitel 04_ Lea ist krank (Folge 3).mp3", 1639033, 1787745946], ["12 - Kapitel 05_ Lea ist krank (Folge 3).mp3", 1585535, 1787745947], ["13 - Kapitel 06_ Lea ist krank (Folge 3).mp3", 1590132, 1787745948], ["14 - Kapitel 07_ Lea ist krank (Folge 3).mp3", 2407661, 1787745951], ["15 - Kapitel 01_ Der Drachen im Baum (Folge 3).mp3", 1649912, 1787745947], ["16 - Kapitel 02_ Der Drachen im Baum (Folge 3).mp3", 1546258, 1787745948], ["17 - Kapitel 03_ Der Drachen im Baum (Folge 3).mp3", 1701739, 1787745949], ["18 - Kapitel 04_ Der Drachen im Baum (Folge 3).mp3", 1667049, 1787745952], ["19 - Kapitel 05_ Der Drachen im Baum (Folge 3).mp3", 1618147, 1787745949], ["20 - Kapitel 06_ Der Drachen im Baum (Folge 3).mp3", 1628596, 1787745953], ["21 - Kapitel 07_ Der Drachen im Baum (Folge 3).mp3", 1609788, 1787745952], ["22 - Kapitel 08_ Der Drachen im Baum (Folge 3).mp3", 2118446, 1787745952], ["23 - Kapitel 01_ Müde gespielt (Folge 3).mp3", 1630674, 1787745950], ["24 - Kapitel 02_ Müde gespielt (Folge 3).mp3", 1691278, 1787745950], ["25 - Kapitel 03_ Müde gespielt (Folge 3).mp3", 1544993, 1787745951], ["26 - Kapitel 04_ Müde gespielt (Folge 3).mp3", 1663275, 1787745953], ["27 - Kapitel 05_ Müde gespielt (Folge 3).mp3", 1599327, 1787745953], ["28 - Kapitel 06_ Müde gespielt (Folge 3).mp3", 1496509, 1787745953], ["29 - Kapitel 07_ Müde gespielt (Folge 3).mp3", 1711758, 1787745952]]}, {"id": "edde307092a8", "section": "Hörbücher", "kind": "book", "title": "Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)", "artist": "Bobo Siebenschläfer, Markus Osterwalder", "series": "Bobo Siebenschläfer", "figure": null, "colors": ["#0091a1", "#e87159", "#e8000a"], "folder": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)", "cover": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/01 - Kapitel 1 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", "title": "Kapitel 1 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1)", "duration": 22.77877551020408, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/02 - Kapitel 2 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", "title": "Kapitel 2 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1)", "duration": 405.0285714285714, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/03 - Kapitel 3 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", "title": "Kapitel 3 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1)", "duration": 355.03020408163263, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/04 - Kapitel 4 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", "title": "Kapitel 4 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1)", "duration": 370.05061224489793, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/05 - Kapitel 5 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", "title": "Kapitel 5 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1)", "duration": 351.0334693877551, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/06 - Kapitel 6 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", "title": "Kapitel 6 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1)", "duration": 409.0514285714286, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen (Band 1) (Original-Hörspiel zur TV-Kinderserie)/07 - Kapitel 7 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", "title": "Kapitel 7 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1)", "duration": 393.0383673469388, "analysis": null}], "files": [["01 - Kapitel 1 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", 502579, 1787744146], ["02 - Kapitel 2 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", 6618577, 1787744152], ["03 - Kapitel 3 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", 5818603, 1787744150], ["04 - Kapitel 4 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", 6058929, 1787744149], ["05 - Kapitel 5 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", 5754655, 1787744149], ["06 - Kapitel 6 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", 6682942, 1787744152], ["07 - Kapitel 7 (Bobo Siebenschläfer. Bobo besucht den Zoo und weitere Folgen Band 1).mp3", 6426733, 1787744149]]}, {"id": "c153c4192bdd", "section": "Hörbücher", "kind": "book", "title": "Bobo Siebenschläfer. Großer Sommerspaß.", "artist": "Bobo Siebenschläfer, Markus Osterwalder", "series": "Bobo Siebenschläfer", "figure": null, "colors": ["#78c3f0", "#b79772", "#fee097"], "folder": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß", "cover": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/01 - Kapitel 1 & Kapitel 2 & Kapitel 3.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 1 & Kapitel 2 & Kapitel 3.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 199.15755102040816, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/02 - Kapitel 3.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 3.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 126.53714285714285, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/03 - Kapitel 3.3 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 3.3 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 130.2465306122449, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/04 - Kapitel 3.4 & Kapitel 4 & Kapitel 5.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 3.4 & Kapitel 4 & Kapitel 5.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 135.18367346938774, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/05 - Kapitel 5.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 5.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 136.75102040816327, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/06 - Kapitel 5.3 & Kapitel 6 & Kapitel 7.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 5.3 & Kapitel 6 & Kapitel 7.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 125.7534693877551, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/07 - Kapitel 7.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 7.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 126.64163265306122, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/08 - Kapitel 7.3 & Kapitel 8 & Kapitel 9.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 7.3 & Kapitel 8 & Kapitel 9.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 179.5395918367347, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/09 - Kapitel 9.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 9.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 127.63428571428571, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/10 - Kapitel 9.3 & Kapitel 10 & Kapitel 11.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 9.3 & Kapitel 10 & Kapitel 11.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 181.02857142857144, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/11 - Kapitel 11.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 11.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 125.44, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/12 - Kapitel 11.3 & Kapitel 12 & Kapitel 13.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 11.3 & Kapitel 12 & Kapitel 13.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 131.57877551020408, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/13 - Kapitel 13.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 13.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 129.14938775510205, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/14 - Kapitel 13.3 & Kapitel 14 & Kapitel 15.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 13.3 & Kapitel 14 & Kapitel 15.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 252.60408163265305, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/15 - Kapitel 15.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 15.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 133.74693877551022, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/16 - Kapitel 15.3 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 15.3 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 130.42938775510203, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/17 - Kapitel 15.4 & Kapitel 16 & Kapitel 17.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 15.4 & Kapitel 16 & Kapitel 17.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 126.56326530612245, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/18 - Kapitel 17.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 17.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 131.13469387755103, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/19 - Kapitel 17.3 & Kapitel 18.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 17.3 & Kapitel 18.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 129.17551020408163, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/20 - Kapitel 18.2 & Kapitel 19.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 18.2 & Kapitel 19.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 125.98857142857143, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/21 - Kapitel 19.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 19.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 134.7395918367347, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/22 - Kapitel 19.3 & Kapitel 20.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 19.3 & Kapitel 20.1 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 207.0987755102041, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Bobo Siebenschläfer - Bobo Siebenschläfer. Großer Sommerspaß/23 - Kapitel 20.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", "title": "Kapitel 20.2 - Bobo Siebenschläfer. Großer Sommerspaß.", "duration": 7.183673469387755, "analysis": null}], "files": [["01 - Kapitel 1 & Kapitel 2 & Kapitel 3.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 3307219, 1787744237], ["02 - Kapitel 3.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2145245, 1787744237], ["03 - Kapitel 3.3 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2204595, 1787744232], ["04 - Kapitel 3.4 & Kapitel 4 & Kapitel 5.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2283641, 1787744238], ["05 - Kapitel 5.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2308667, 1787744237], ["06 - Kapitel 5.3 & Kapitel 6 & Kapitel 7.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2132758, 1787744234], ["07 - Kapitel 7.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2146917, 1787744236], ["08 - Kapitel 7.3 & Kapitel 8 & Kapitel 9.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2993336, 1787744233], ["09 - Kapitel 9.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2162799, 1787744232], ["10 - Kapitel 9.3 & Kapitel 10 & Kapitel 11.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 3017166, 1787744238], ["11 - Kapitel 11.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2127695, 1787744234], ["12 - Kapitel 11.3 & Kapitel 12 & Kapitel 13.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2225971, 1787744234], ["13 - Kapitel 13.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2187045, 1787744234], ["14 - Kapitel 13.3 & Kapitel 14 & Kapitel 15.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 4162376, 1787744236], ["15 - Kapitel 15.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2260606, 1787744236], ["16 - Kapitel 15.3 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2207525, 1787744238], ["17 - Kapitel 15.4 & Kapitel 16 & Kapitel 17.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2145723, 1787744235], ["18 - Kapitel 17.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2218810, 1787744237], ["19 - Kapitel 17.3 & Kapitel 18.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2187493, 1787744237], ["20 - Kapitel 18.2 & Kapitel 19.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2136502, 1787744239], ["21 - Kapitel 19.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 2276488, 1787744238], ["22 - Kapitel 19.3 & Kapitel 20.1 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 3434265, 1787744238], ["23 - Kapitel 20.2 - Bobo Siebenschläfer. Großer Sommerspaß.mp3", 235591, 1787744237]]}, {"id": "9496cc3fa702", "section": "Hörbücher", "kind": "book", "title": "Conni geht verloren / Conni in den Bergen", "artist": "Conni", "series": "Conni", "figure": null, "colors": ["#fce16e", "#8cb63f", "#d88096"], "folder": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen", "cover": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/01 - Meine beste Freundin Conni (Intro-Song).mp3", "title": "Meine beste Freundin Conni (Intro-Song)", "duration": 77.42693877551021, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/02 - Conni geht verloren - Teil 01.mp3", "title": "Conni geht verloren - Teil 01", "duration": 147.72244897959183, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/03 - Conni geht verloren - Teil 02.mp3", "title": "Conni geht verloren - Teil 02", "duration": 91.14122448979592, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/04 - Conni geht verloren - Teil 03.mp3", "title": "Conni geht verloren - Teil 03", "duration": 109.4530612244898, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/05 - Conni geht verloren - Teil 04.mp3", "title": "Conni geht verloren - Teil 04", "duration": 87.14448979591836, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/06 - Conni geht verloren - Teil 05.mp3", "title": "Conni geht verloren - Teil 05", "duration": 82.46857142857142, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/07 - Conni geht verloren - Teil 06.mp3", "title": "Conni geht verloren - Teil 06", "duration": 83.90530612244898, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/08 - Conni geht verloren - Teil 07.mp3", "title": "Conni geht verloren - Teil 07", "duration": 81.44979591836734, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/09 - Conni geht verloren - Teil 08.mp3", "title": "Conni geht verloren - Teil 08", "duration": 82.10285714285715, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/10 - Conni geht verloren - Teil 09.mp3", "title": "Conni geht verloren - Teil 09", "duration": 123.40244897959184, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/11 - Conni in den Bergen - Teil 01.mp3", "title": "Conni in den Bergen - Teil 01", "duration": 83.8269387755102, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/12 - Conni in den Bergen - Teil 02.mp3", "title": "Conni in den Bergen - Teil 02", "duration": 103.34040816326531, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/13 - Conni in den Bergen - Teil 03.mp3", "title": "Conni in den Bergen - Teil 03", "duration": 115.98367346938775, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/14 - Conni in den Bergen - Teil 04.mp3", "title": "Conni in den Bergen - Teil 04", "duration": 100.12734693877552, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/15 - Conni in den Bergen - Teil 05.mp3", "title": "Conni in den Bergen - Teil 05", "duration": 93.80571428571429, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/16 - Conni in den Bergen - Teil 06.mp3", "title": "Conni in den Bergen - Teil 06", "duration": 100.04897959183674, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/17 - Conni in den Bergen - Teil 07.mp3", "title": "Conni in den Bergen - Teil 07", "duration": 98.14204081632653, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/18 - Conni in den Bergen - Teil 08.mp3", "title": "Conni in den Bergen - Teil 08", "duration": 116.97632653061224, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/19 - Conni in den Bergen - Teil 09.mp3", "title": "Conni in den Bergen - Teil 09", "duration": 80.71836734693878, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/20 - Conni in den Bergen - Teil 10.mp3", "title": "Conni in den Bergen - Teil 10", "duration": 80.74448979591837, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/21 - Conni in den Bergen - Teil 11.mp3", "title": "Conni in den Bergen - Teil 11", "duration": 83.67020408163265, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/22 - Conni in den Bergen - Teil 12.mp3", "title": "Conni in den Bergen - Teil 12", "duration": 90.01795918367347, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni geht verloren _ Conni in den Bergen/23 - Meine beste Freundin Conni (Outro-Song).mp3", "title": "Meine beste Freundin Conni (Outro-Song)", "duration": 77.21795918367347, "analysis": null}], "files": [["01 - Meine beste Freundin Conni (Intro-Song).mp3", 1420195, 1787744834], ["02 - Conni geht verloren - Teil 01.mp3", 2544904, 1787744830], ["03 - Conni geht verloren - Teil 02.mp3", 1594907, 1787744830], ["04 - Conni geht verloren - Teil 03.mp3", 1932594, 1787744834], ["05 - Conni geht verloren - Teil 04.mp3", 1575655, 1787744833], ["06 - Conni geht verloren - Teil 05.mp3", 1500841, 1787744834], ["07 - Conni geht verloren - Teil 06.mp3", 1523828, 1787744830], ["08 - Conni geht verloren - Teil 07.mp3", 1484540, 1787744831], ["09 - Conni geht verloren - Teil 08.mp3", 1494989, 1787744834], ["10 - Conni geht verloren - Teil 09.mp3", 2155786, 1787744830], ["11 - Conni in den Bergen - Teil 01.mp3", 1522577, 1787744831], ["12 - Conni in den Bergen - Teil 02.mp3", 1834793, 1787744831], ["13 - Conni in den Bergen - Teil 03.mp3", 2037085, 1787744834], ["14 - Conni in den Bergen - Teil 04.mp3", 1783384, 1787744831], ["15 - Conni in den Bergen - Teil 05.mp3", 1682237, 1787744832], ["16 - Conni in den Bergen - Teil 06.mp3", 1782130, 1787744832], ["17 - Conni in den Bergen - Teil 07.mp3", 1751618, 1787744833], ["18 - Conni in den Bergen - Teil 08.mp3", 2052968, 1787744833], ["19 - Conni in den Bergen - Teil 09.mp3", 1472839, 1787744833], ["20 - Conni in den Bergen - Teil 10.mp3", 1473257, 1787744833], ["21 - Conni in den Bergen - Teil 11.mp3", 1520069, 1787744833], ["22 - Conni in den Bergen - Teil 12.mp3", 1621633, 1787744834], ["23 - Meine beste Freundin Conni (Outro-Song).mp3", 1416853, 1787744834]]}, {"id": "6851130ab693", "section": "Hörbücher", "kind": "book", "title": "Conni hat Kummer / Conni lernt die Uhrzeit", "artist": "Conni", "series": "Conni", "figure": null, "colors": ["#a7d4f6", "#f2b182", "#7a8bc7"], "folder": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit", "cover": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/cover.jpg", "tracks": [{"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/01 - Meine beste Freundin Conni (Intro-Song).mp3", "title": "Meine beste Freundin Conni (Intro-Song)", "duration": 74.99755102040817, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/02 - Conni hat Kummer - Teil 01.mp3", "title": "Conni hat Kummer - Teil 01", "duration": 141.19183673469388, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/03 - Conni hat Kummer - Teil 02.mp3", "title": "Conni hat Kummer - Teil 02", "duration": 121.33877551020409, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/04 - Conni hat Kummer - Teil 03.mp3", "title": "Conni hat Kummer - Teil 03", "duration": 95.94775510204082, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/05 - Conni hat Kummer - Teil 04.mp3", "title": "Conni hat Kummer - Teil 04", "duration": 92.31673469387755, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/06 - Conni hat Kummer - Teil 05.mp3", "title": "Conni hat Kummer - Teil 05", "duration": 120.0065306122449, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/07 - Conni hat Kummer - Teil 06.mp3", "title": "Conni hat Kummer - Teil 06", "duration": 117.96897959183673, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/08 - Conni hat Kummer - Teil 07.mp3", "title": "Conni hat Kummer - Teil 07", "duration": 101.4334693877551, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/09 - Conni hat Kummer - Teil 08.mp3", "title": "Conni hat Kummer - Teil 08", "duration": 130.8734693877551, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/10 - Conni hat Kummer - Teil 09.mp3", "title": "Conni hat Kummer - Teil 09", "duration": 140.27755102040817, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/11 - Conni lernt die Uhrzeit - Teil 01.mp3", "title": "Conni lernt die Uhrzeit - Teil 01", "duration": 102.32163265306123, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/12 - Conni lernt die Uhrzeit - Teil 02.mp3", "title": "Conni lernt die Uhrzeit - Teil 02", "duration": 103.57551020408164, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/13 - Conni lernt die Uhrzeit - Teil 03.mp3", "title": "Conni lernt die Uhrzeit - Teil 03", "duration": 90.43591836734694, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/14 - Conni lernt die Uhrzeit - Teil 04.mp3", "title": "Conni lernt die Uhrzeit - Teil 04", "duration": 93.64897959183673, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/15 - Conni lernt die Uhrzeit - Teil 05.mp3", "title": "Conni lernt die Uhrzeit - Teil 05", "duration": 98.53387755102041, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/16 - Conni lernt die Uhrzeit - Teil 06.mp3", "title": "Conni lernt die Uhrzeit - Teil 06", "duration": 95.73877551020408, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/17 - Conni lernt die Uhrzeit - Teil 07.mp3", "title": "Conni lernt die Uhrzeit - Teil 07", "duration": 103.18367346938776, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/18 - Conni lernt die Uhrzeit - Teil 08.mp3", "title": "Conni lernt die Uhrzeit - Teil 08", "duration": 127.6865306122449, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/19 - Conni lernt die Uhrzeit - Teil 09.mp3", "title": "Conni lernt die Uhrzeit - Teil 09", "duration": 120.0065306122449, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/20 - Conni lernt die Uhrzeit - Teil 10.mp3", "title": "Conni lernt die Uhrzeit - Teil 10", "duration": 117.4465306122449, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/21 - Conni lernt die Uhrzeit - Teil 11.mp3", "title": "Conni lernt die Uhrzeit - Teil 11", "duration": 105.19510204081632, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/22 - Conni lernt die Uhrzeit - Teil 12.mp3", "title": "Conni lernt die Uhrzeit - Teil 12", "duration": 89.2865306122449, "analysis": null}, {"path": "/home/martin/Music/Hörbücher/Conni - Conni hat Kummer _ Conni lernt die Uhrzeit/23 - Meine beste Freundin Conni (Outro-Song).mp3", "title": "Meine beste Freundin Conni (Outro-Song)", "duration": 76.53877551020408, "analysis": null}], "files": [["01 - Meine beste Freundin Conni (Intro-Song).mp3", 1347781, 1787744607], ["02 - Conni hat Kummer - Teil 01.mp3", 2406865, 1787744607], ["03 - Conni hat Kummer - Teil 02.mp3", 2089216, 1787744607], ["04 - Conni hat Kummer - Teil 03.mp3", 1682959, 1787744607], ["05 - Conni hat Kummer - Teil 04.mp3", 1624862, 1787744610], ["06 - Conni hat Kummer - Teil 05.mp3", 2067900, 1787744608], ["07 - Conni hat Kummer - Teil 06.mp3", 2035299, 1787744608], ["08 - Conni hat Kummer - Teil 07.mp3", 1770731, 1787744608], ["09 - Conni hat Kummer - Teil 08.mp3", 2241771, 1787744610], ["10 - Conni hat Kummer - Teil 09.mp3", 2392238, 1787744611], ["11 - Conni lernt die Uhrzeit - Teil 01.mp3", 1784958, 1787744608], ["12 - Conni lernt die Uhrzeit - Teil 02.mp3", 1805020, 1787744608], ["13 - Conni lernt die Uhrzeit - Teil 03.mp3", 1594785, 1787744609], ["14 - Conni lernt die Uhrzeit - Teil 04.mp3", 1646194, 1787744609], ["15 - Conni lernt die Uhrzeit - Teil 05.mp3", 1724353, 1787744610], ["16 - Conni lernt die Uhrzeit - Teil 06.mp3", 1679631, 1787744610], ["17 - Conni lernt die Uhrzeit - Teil 07.mp3", 1798750, 1787744610], ["18 - Conni lernt die Uhrzeit - Teil 08.mp3", 2190796, 1787744610], ["19 - Conni lernt die Uhrzeit - Teil 09.mp3", 2067916, 1787744610], ["20 - Conni lernt die Uhrzeit - Teil 10.mp3", 2026956, 1787744611], ["21 - Conni lernt die Uhrzeit - Teil 11.mp3", 1830933, 1787744611], ["22 - Conni lernt die Uhrzeit - Teil 12.mp3", 1576395, 1787744611], ["23 - Meine beste Freundin Conni (Outro-Song).mp3", 1372443, 1787744611]]}, {"id": "8872dc9d5969", "section": "Kinderpodcasts", "kind": "book", "title": "Die Sendung mit der Maus", "artist": "Die Sendung mit der Maus", "series": "Die Sendung mit der Maus", "figure": null, "colors": ["#43f278", "#4dccb2", "#e544b5"], "folder": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus", "cover": null, "tracks": [{"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260821 - Gottesanbeterin – Wie fängt sie ihre Beute_.mp3", "title": "Gottesanbeterin – Wie fängt sie ihre Beute?", "duration": 417.6, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260814 - Stadtschafe -Teil 3 „Spätsommer“.mp3", "title": "Stadtschafe -Teil 3 „Spätsommer“", "duration": 568.56, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260807 - Haare für den Umweltschutz.mp3", "title": "Haare für den Umweltschutz", "duration": 683.4, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260710 - Wie wird eine Toilettenschüssel gemacht_.mp3", "title": "Wie wird eine Toilettenschüssel gemacht?", "duration": 757.8, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260703 - Stadtschafe - Teil 2_ Frühsommer.mp3", "title": "Stadtschafe - Teil 2: Frühsommer", "duration": 555.912, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260619 - Leben auf dem Hausboot.mp3", "title": "Leben auf dem Hausboot", "duration": 461.472, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260605 - Helenes neues Ohr – CI Implantat.mp3", "title": "Helenes neues Ohr – CI Implantat", "duration": 475.872, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260206 - Defibrillator_ Was sind das für Kästen an der Wand_.mp3", "title": "Defibrillator: Was sind das für Kästen an der Wand?", "duration": 484.32, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260130 - Schlitten aus Kunststoff.mp3", "title": "Schlitten aus Kunststoff", "duration": 492.96, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260123 - Druck Mindesthaltbarkeitsdatum.mp3", "title": "Druck Mindesthaltbarkeitsdatum", "duration": 472.56, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260116 - Furzstuhl.mp3", "title": "Furzstuhl", "duration": 595.512, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260109 - Gondelrettung.mp3", "title": "Gondelrettung", "duration": 454.152, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20260102 - Wie funktioniert ein Schrittzähler_.mp3", "title": "Wie funktioniert ein Schrittzähler?", "duration": 423.12, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251226 - Wie werden Tischfeuerwerk und Knallbonbons hergestellt_.mp3", "title": "Wie werden Tischfeuerwerk und Knallbonbons hergestellt?", "duration": 509.784, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251219 - Weihnachtsbaum aus dem 3D-Drucker.mp3", "title": "Weihnachtsbaum aus dem 3D-Drucker", "duration": 491.472, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251128 - Kontaktlinsen.mp3", "title": "Kontaktlinsen", "duration": 888.72, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251121 - JVA – Welche Berufe gibt es im Gefängnis_.mp3", "title": "JVA – Welche Berufe gibt es im Gefängnis?", "duration": 1515.456, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251114 - Supermarkt aus Holzbausteinen.mp3", "title": "Supermarkt aus Holzbausteinen", "duration": 576.84, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251107 - Blindenfußball.mp3", "title": "Blindenfußball", "duration": 501.216, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251017 - Wie werden Baumsamen gewonnen_.mp3", "title": "Wie werden Baumsamen gewonnen?", "duration": 700.08, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251010 - Flexibles Wohnen.mp3", "title": "Flexibles Wohnen", "duration": 607.08, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20251003 - Apfelsaft pressen.mp3", "title": "Apfelsaft pressen", "duration": 332.328, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250926 - Textilrecycling.mp3", "title": "Textilrecycling", "duration": 568.344, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250919 - Rollen-Herstellung.mp3", "title": "Rollen-Herstellung", "duration": 927.192, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250912 - Radierbare Gelstifte.mp3", "title": "Radierbare Gelstifte", "duration": 421.272, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250822 - Mein Zuhause ist anders – Jolandas Baumhaus.mp3", "title": "Mein Zuhause ist anders – Jolandas Baumhaus", "duration": 663.024, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250801 - Streusalz aus Gurkenwasser.mp3", "title": "Streusalz aus Gurkenwasser", "duration": 1084.92, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250704 - Fassadenbegrünung.mp3", "title": "Fassadenbegrünung", "duration": 1106.52, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250627 - Update Maus-Baum 2025.mp3", "title": "Update Maus-Baum 2025", "duration": 297.816, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250620 - Wie schreibt eine KI Texte_.mp3", "title": "Wie schreibt eine KI Texte?", "duration": 360.888, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250613 - Bergfeuer (Teil 1 und 2).mp3", "title": "Bergfeuer (Teil 1 und 2)", "duration": 900.096, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250606 - Schiffshebewerk Niederfinow.mp3", "title": "Schiffshebewerk Niederfinow", "duration": 684.048, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250530 - Margeritensamen.mp3", "title": "Margeritensamen", "duration": 631.344, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250516 - Wie funktioniert der Küstenschutz auf Sylt_.mp3", "title": "Wie funktioniert der Küstenschutz auf Sylt?", "duration": 732.72, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250502 - Wofür braucht der Maikäfer seine Fühler_.mp3", "title": "Wofür braucht der Maikäfer seine Fühler?", "duration": 402.048, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250404 - Lauras Machgeschichten · Der Löffel-Lüfter.mp3", "title": "Lauras Machgeschichten · Der Löffel-Lüfter", "duration": 549.888, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250328 - Modellauto (mit Gebärdensprache).mp3", "title": "Modellauto (mit Gebärdensprache)", "duration": 521.304, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250321 - Magnetresonanztomografie.mp3", "title": "Magnetresonanztomografie", "duration": 897.048, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250314 - Lauras Machgeschichten · Die Gabel-Zange.mp3", "title": "Lauras Machgeschichten · Die Gabel-Zange", "duration": 320.712, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250307 - Zaunkönig.mp3", "title": "Zaunkönig", "duration": 452.736, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250228 - Wieso rutscht man auf einer Bananenschale aus_.mp3", "title": "Wieso rutscht man auf einer Bananenschale aus?", "duration": 563.76, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250221 - Alex' Südpol-Mission · Teil 2.mp3", "title": "Alex' Südpol-Mission · Teil 2", "duration": 804.288, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250214 - Alex' Südpol-Mission · Teil 1.mp3", "title": "Alex' Südpol-Mission · Teil 1", "duration": 775.56, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250131 - Rico und Oskar · Die Flirtschule.mp3", "title": "Rico und Oskar · Die Flirtschule", "duration": 397.872, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250117 - Kita heben.mp3", "title": "Kita heben", "duration": 535.296, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20250110 - Apfelplantage _ Winter.mp3", "title": "Apfelplantage | Winter", "duration": 882.456, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241227 - Raumschiffbau für die Mondmission.mp3", "title": "Raumschiffbau für die Mondmission", "duration": 1190.88, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241220 - Wie wird eine Lichterkette gemacht_.mp3", "title": "Wie wird eine Lichterkette gemacht?", "duration": 577.56, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241206 - Wie funktioniert eine Bowling-Bahn_.mp3", "title": "Wie funktioniert eine Bowling-Bahn?", "duration": 448.632, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241122 - Wie funktioniert das Bundesverfassungsgericht_.mp3", "title": "Wie funktioniert das Bundesverfassungsgericht?", "duration": 1053.96, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241108 - Lauras Machgeschichten_ Das Besser-Messer.mp3", "title": "Lauras Machgeschichten: Das Besser-Messer", "duration": 488.472, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241101 - Wie viel Energie erzeugt ein Windrad bei einer Umdrehung_.mp3", "title": "Wie viel Energie erzeugt ein Windrad bei einer Umdrehung?", "duration": 597.288, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241025 - Vogel auf dem Draht.mp3", "title": "Vogel auf dem Draht", "duration": 379.92, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241018 - Klodeckel mit Absenkautomatik.mp3", "title": "Klodeckel mit Absenkautomatik", "duration": 491.784, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241011 - Apfelplantage Teil 3_ Herbst.mp3", "title": "Apfelplantage Teil 3: Herbst", "duration": 803.184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20241005 - Harrys Sprachmobil.mp3", "title": "Harrys Sprachmobil", "duration": 358.968, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240929 - Kaffeebecher aus Kaffeesatz.mp3", "title": "Kaffeebecher aus Kaffeesatz", "duration": 1319.4, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240927 - Ein Quadratmeter Natur_ Rheinufer.mp3", "title": "Ein Quadratmeter Natur: Rheinufer", "duration": 484.824, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240920 - Seltene Erde.mp3", "title": "Seltene Erde", "duration": 485.28, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240913 - Pfadfinder.mp3", "title": "Pfadfinder", "duration": 727.848, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240906 - Ampelschaltung.mp3", "title": "Ampelschaltung", "duration": 491.544, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240830 - Solidarische Landwirtschaft.mp3", "title": "Solidarische Landwirtschaft", "duration": 675.24, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240823 - Esel - Wie ist er zum Haustier geworden_.mp3", "title": "Esel - Wie ist er zum Haustier geworden?", "duration": 394.536, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240816 - Blobben mit André.mp3", "title": "Blobben mit André", "duration": 332.52, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240809 - Türklinke.mp3", "title": "Türklinke", "duration": 497.88, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240719 - Johannes Olympia Top Ten.mp3", "title": "Johannes Olympia Top Ten", "duration": 666.552, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240712 - Horizontalbohrer.mp3", "title": "Horizontalbohrer", "duration": 518.328, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240705 - Lupinen-Schnitzel.mp3", "title": "Lupinen-Schnitzel", "duration": 504.504, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240628 - Apfelplantage_ Sommer.mp3", "title": "Apfelplantage: Sommer", "duration": 686.376, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240621 - Ameisenlöwe_ Wie fängt er seine Beute_ (mit Gebärdensprache).mp3", "title": "Ameisenlöwe: Wie fängt er seine Beute? (mit Gebärdensprache)", "duration": 491.784, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240614 - Immer geradeaus.mp3", "title": "Immer geradeaus", "duration": 481.752, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240607 - Was ist eine Flugzeugdrohne_.mp3", "title": "Was ist eine Flugzeugdrohne?", "duration": 528.792, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240531 - Kraftstoff aus Frittenfett.mp3", "title": "Kraftstoff aus Frittenfett", "duration": 906.768, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240524 - Sachgeschichte_ Longboard.mp3", "title": "Sachgeschichte: Longboard", "duration": 577.392, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240517 - Sachgeschichte_ Warum hält der Neoprenanzug warm_.mp3", "title": "Sachgeschichte: Warum hält der Neoprenanzug warm?", "duration": 746.808, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240510 - Sachgeschichte_ Regentropfen.mp3", "title": "Sachgeschichte: Regentropfen", "duration": 418.584, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240409 - Können Fische hören_.mp3", "title": "Können Fische hören?", "duration": 438.744, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240407 - Wie wird ein Wald aufgeforstet_.mp3", "title": "Wie wird ein Wald aufgeforstet?", "duration": 568.032, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240407 - Wie pinkeln Vögel_.mp3", "title": "Wie pinkeln Vögel?", "duration": 410.64, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240324 - Woher kommen unsere Schuhgrößen_.mp3", "title": "Woher kommen unsere Schuhgrößen?", "duration": 761.52, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240321 - Ostereierfarben.mp3", "title": "Ostereierfarben", "duration": 385.512, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240321 - Bunte Eier.mp3", "title": "Bunte Eier", "duration": 201.912, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240228 - Was ist eine Kartoffelpflanzmaschine_.mp3", "title": "Was ist eine Kartoffelpflanzmaschine?", "duration": 296.472, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240225 - Mona und ihre seltene Krankheit.mp3", "title": "Mona und ihre seltene Krankheit", "duration": 1462.728, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240204 - Sachgeschichte_ Eisbärfigur.mp3", "title": "Sachgeschichte: Eisbärfigur", "duration": 365.52, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240123 - Wie öffnet sich ein Kiefernzapfen_.mp3", "title": "Wie öffnet sich ein Kiefernzapfen?", "duration": 395.616, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240122 - Wie wird das Maus-Krätzchen genäht_.mp3", "title": "Wie wird das Maus-Krätzchen genäht?", "duration": 414.288, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240117 - Wie entsteht ein Drehwurm_.mp3", "title": "Wie entsteht ein Drehwurm?", "duration": 786.528, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240106 - Wie sieht ein CO2-Molekül aus_.mp3", "title": "Wie sieht ein CO2-Molekül aus?", "duration": 626.088, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240105 - Wie wird Haferdrink hergestellt_.mp3", "title": "Wie wird Haferdrink hergestellt?", "duration": 427.368, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240105 - Wie werden Wendepailletten angenäht_.mp3", "title": "Wie werden Wendepailletten angenäht?", "duration": 559.32, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20240105 - Wie funktioniert ein Seifenspender_.mp3", "title": "Wie funktioniert ein Seifenspender?", "duration": 462.6, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231228 - Wie wird ein riesiger Schneemann gebaut_.mp3", "title": "Wie wird ein riesiger Schneemann gebaut?", "duration": 781.392, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231205 - Warum fällen Biber Bäume_.mp3", "title": "Warum fällen Biber Bäume?", "duration": 506.904, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231116 - Weihnachtsfrucht.mp3", "title": "Weihnachtsfrucht", "duration": 216.792, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231116 - Weihnachtsbaum schmückt sich von selbst.mp3", "title": "Weihnachtsbaum schmückt sich von selbst", "duration": 231.0, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231116 - Lauras Machgeschichte_ Pinguin mit Keksversteck.mp3", "title": "Lauras Machgeschichte: Pinguin mit Keksversteck", "duration": 411.312, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231110 - Leverkusener Autobahnbrücke - Folge 7.mp3", "title": "Leverkusener Autobahnbrücke - Folge 7", "duration": 716.088, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231025 - Leverkusener Autobahnbrücke (Teil 6).mp3", "title": "Leverkusener Autobahnbrücke (Teil 6)", "duration": 453.744, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231025 - Leverkusener Autobahnbrücke (Teil 3).mp3", "title": "Leverkusener Autobahnbrücke (Teil 3)", "duration": 698.208, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231025 - Leverkusener Autobahnbrücke (Teil 2).mp3", "title": "Leverkusener Autobahnbrücke (Teil 2)", "duration": 791.184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231023 - Wie wächst das Hirschgeweih_.mp3", "title": "Wie wächst das Hirschgeweih?", "duration": 466.536, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231023 - Papiertaschentücher.mp3", "title": "Papiertaschentücher", "duration": 508.08, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231023 - Laubkompost.mp3", "title": "Laubkompost", "duration": 432.432, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20231023 - Blattfarben.mp3", "title": "Blattfarben", "duration": 440.088, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230915 - Rheinkrake.mp3", "title": "Rheinkrake", "duration": 811.608, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230914 - Lauras Machgeschichten · Leuchtender Regenschirm.mp3", "title": "Lauras Machgeschichten · Leuchtender Regenschirm", "duration": 384.936, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230913 - Zauberstifte.mp3", "title": "Zauberstifte", "duration": 326.64, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230913 - Schulkantine.mp3", "title": "Schulkantine", "duration": 769.704, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230913 - Früher-Heute-Geschichten_ Schulstuhl.mp3", "title": "Früher-Heute-Geschichten: Schulstuhl", "duration": 624.408, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230825 - Ein Quadratmeter Natur – Landschaftspark Nord.mp3", "title": "Ein Quadratmeter Natur – Landschaftspark Nord", "duration": 468.0, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230816 - Lauras Machgeschichten_ Zuckerwatte-Maschine.mp3", "title": "Lauras Machgeschichten: Zuckerwatte-Maschine", "duration": 555.288, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230816 - Lauras Machgeschichten_ Stein-Flitsch-Maschine.mp3", "title": "Lauras Machgeschichten: Stein-Flitsch-Maschine", "duration": 595.032, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230816 - Lauras Machgeschichten_ Autospritzdüse wird Quatschkamera.mp3", "title": "Lauras Machgeschichten: Autospritzdüse wird Quatschkamera", "duration": 470.808, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230804 - Wie funktioniert Freistoß-Spray_.mp3", "title": "Wie funktioniert Freistoß-Spray?", "duration": 486.384, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230803 - Wie lebt ein Feuersalamander_.mp3", "title": "Wie lebt ein Feuersalamander?", "duration": 475.608, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230629 - Sachgeschichte_ Marokko-Maus Teil 4b.mp3", "title": "Sachgeschichte: Marokko-Maus Teil 4b", "duration": 568.32, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230629 - Sachgeschichte_ Marokko-Maus Teil 4a.mp3", "title": "Sachgeschichte: Marokko-Maus Teil 4a", "duration": 350.4, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230629 - Sachgeschichte_ Marokko-Maus Teil 3a.mp3", "title": "Sachgeschichte: Marokko-Maus Teil 3a", "duration": 354.552, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230629 - Sachgeschichte_ Marokko-Maus Teil 2b.mp3", "title": "Sachgeschichte: Marokko-Maus Teil 2b", "duration": 464.88, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230629 - Sachgeschichte_ Marokko-Maus Teil 2a.mp3", "title": "Sachgeschichte: Marokko-Maus Teil 2a", "duration": 404.76, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230629 - Sachgeschichte_ Marokko-Maus Teil 1a.mp3", "title": "Sachgeschichte: Marokko-Maus Teil 1a", "duration": 408.792, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230620 - Eis am Stiel.mp3", "title": "Eis am Stiel", "duration": 516.864, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230619 - Warum wollen Wespen unser Essen_.mp3", "title": "Warum wollen Wespen unser Essen?", "duration": 434.76, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230618 - Lauras Machgeschichten_ Hängematte ohne Baum.mp3", "title": "Lauras Machgeschichten: Hängematte ohne Baum", "duration": 467.544, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230617 - Kann man Metall mit Wasser schneiden_.mp3", "title": "Kann man Metall mit Wasser schneiden?", "duration": 396.408, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230523 - Sachgeschichte_ Olivia, Willi und die Planeten.mp3", "title": "Sachgeschichte: Olivia, Willi und die Planeten", "duration": 155.112, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230523 - Sachgeschichte_ Autistin Luzi.mp3", "title": "Sachgeschichte: Autistin Luzi", "duration": 492.552, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230514 - Wildbienen.mp3", "title": "Wildbienen", "duration": 447.768, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230427 - Lachgeschichte_ Kraftmacher–Lied für alle _Niemals dran gezweifelt_.mp3", "title": "Lachgeschichte: Kraftmacher–Lied für alle \"Niemals dran gezweifelt\"", "duration": 249.936, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230419 - Haifischhautfolie.mp3", "title": "Haifischhautfolie", "duration": 955.776, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230206 - Jeck Yeah.mp3", "title": "Jeck Yeah", "duration": 250.056, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20230206 - Alemannische Fasnet.mp3", "title": "Alemannische Fasnet", "duration": 431.496, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20221202 - Walnuss.mp3", "title": "Walnuss", "duration": 524.136, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220718 - Sonnenmilch.mp3", "title": "Sonnenmilch", "duration": 375.72, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220603 - Roboter-Bagger.mp3", "title": "Roboter-Bagger", "duration": 302.376, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220408 - Helikopter Windkraftanlagen Reparatur auf hoher See.mp3", "title": "Helikopter Windkraftanlagen Reparatur auf hoher See", "duration": 847.656, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220403 - Ich bin Katja.mp3", "title": "Ich bin Katja", "duration": 895.392, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220316 - Was ist ein Teichrasierer_.mp3", "title": "Was ist ein Teichrasierer?", "duration": 406.992, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220311 - Geigenbogen.mp3", "title": "Geigenbogen", "duration": 630.0, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220308 - Wie läuft der Wasserläufer übers Wasser_.mp3", "title": "Wie läuft der Wasserläufer übers Wasser?", "duration": 370.248, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220308 - Wie lässt man Steine hüpfen_.mp3", "title": "Wie lässt man Steine hüpfen?", "duration": 366.024, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220302 - Was ist Aquaponik_.mp3", "title": "Was ist Aquaponik?", "duration": 568.944, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220301 - Wie funktioniert ein Pumpspeicherwerk_.mp3", "title": "Wie funktioniert ein Pumpspeicherwerk?", "duration": 627.312, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220127 - Sachgeschichte_ Bobbahn.mp3", "title": "Sachgeschichte: Bobbahn", "duration": 865.536, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220116 - Wie wird eine Europalette hergestellt_.mp3", "title": "Wie wird eine Europalette hergestellt?", "duration": 535.704, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220116 - Wie kommen Fahrbahnstreifen auf die Straße_.mp3", "title": "Wie kommen Fahrbahnstreifen auf die Straße?", "duration": 757.392, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220115 - Kardanische Aufhängung.mp3", "title": "Kardanische Aufhängung", "duration": 536.952, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20220109 - Was ist LRS_.mp3", "title": "Was ist LRS?", "duration": 572.112, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211220 - Was ist ein Laser_.mp3", "title": "Was ist ein Laser?", "duration": 672.12, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211220 - Mausprojektion_ Wie kommt die Maus aufs Archivhaus_.mp3", "title": "Mausprojektion: Wie kommt die Maus aufs Archivhaus?", "duration": 496.584, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211215 - Weihnachtsgeschenkpapier.mp3", "title": "Weihnachtsgeschenkpapier", "duration": 503.088, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211215 - Lebensmittelfarbe.mp3", "title": "Lebensmittelfarbe", "duration": 420.24, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211203 - Sachgeschichte_ LED.mp3", "title": "Sachgeschichte: LED", "duration": 521.04, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211012 - Tiba_ Musikunterricht.mp3", "title": "Tiba: Musikunterricht", "duration": 467.304, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211012 - Tiba_ Erster Schultag.mp3", "title": "Tiba: Erster Schultag", "duration": 559.104, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211012 - Tiba_ Die Entscheidung.mp3", "title": "Tiba: Die Entscheidung", "duration": 356.76, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211012 - Tiba_ Beim Augenarzt.mp3", "title": "Tiba: Beim Augenarzt", "duration": 531.12, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20211001 - Brillen aus Wunderbohnen.mp3", "title": "Brillen aus Wunderbohnen", "duration": 646.08, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210813 - Urban Mining (Teil 4).mp3", "title": "Urban Mining (Teil 4)", "duration": 787.872, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210813 - Urban Mining (Teil 3).mp3", "title": "Urban Mining (Teil 3)", "duration": 869.184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210813 - Urban Mining (Teil 2).mp3", "title": "Urban Mining (Teil 2)", "duration": 769.68, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210813 - Urban Mining (Teil 2 mit Gebärdensprache).mp3", "title": "Urban Mining (Teil 2 mit Gebärdensprache)", "duration": 769.68, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210813 - Urban Mining (Teil 1).mp3", "title": "Urban Mining (Teil 1)", "duration": 1003.152, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 6.mp3", "title": "Sachgeschichte: Wanderweg der deutschen Einheit Teil 6", "duration": 598.464, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 5.mp3", "title": "Sachgeschichte: Wanderweg der deutschen Einheit Teil 5", "duration": 612.24, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 4.mp3", "title": "Sachgeschichte: Wanderweg der deutschen Einheit Teil 4", "duration": 678.144, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 3.mp3", "title": "Sachgeschichte: Wanderweg der deutschen Einheit Teil 3", "duration": 620.4, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 2.mp3", "title": "Sachgeschichte: Wanderweg der deutschen Einheit Teil 2", "duration": 554.328, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 1.mp3", "title": "Sachgeschichte: Wanderweg der deutschen Einheit Teil 1", "duration": 654.84, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210809 - Berliner Mauer.mp3", "title": "Berliner Mauer", "duration": 475.272, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210608 - Stadionsitz.mp3", "title": "Stadionsitz", "duration": 569.16, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210606 - Warum ist der Apollofalter so selten_.mp3", "title": "Warum ist der Apollofalter so selten?", "duration": 411.312, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210603 - Brille für Afrika.mp3", "title": "Brille für Afrika", "duration": 485.16, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210603 - Blindenschule.mp3", "title": "Blindenschule", "duration": 1680.912, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210603 - Blindenschrift.mp3", "title": "Blindenschrift", "duration": 369.12, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210527 - Flamingoschnabel.mp3", "title": "Flamingoschnabel", "duration": 448.728, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210509 - Sachgeschichte_ Herzsymbol.mp3", "title": "Sachgeschichte: Herzsymbol", "duration": 416.28, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210430 - So funktioniert Urban Gardening.mp3", "title": "So funktioniert Urban Gardening", "duration": 465.912, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210430 - Ein Küken kommt aus dem Ei.mp3", "title": "Ein Küken kommt aus dem Ei", "duration": 792.936, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210307 - Wie hält ein Torwart sein Tor sauber_.mp3", "title": "Wie hält ein Torwart sein Tor sauber?", "duration": 355.176, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210307 - Seepferdchen.mp3", "title": "Seepferdchen", "duration": 421.2, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210307 - Lachgeschichte_ Planet Willi.mp3", "title": "Lachgeschichte: Planet Willi", "duration": 478.512, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210307 - Der Muffin Code.mp3", "title": "Der Muffin Code", "duration": 359.184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210303 - Suchmaschine.mp3", "title": "Suchmaschine", "duration": 528.432, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Überträgt sich das Virus über Gegenstände_.mp3", "title": "Überträgt sich das Virus über Gegenstände?", "duration": 174.792, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Woher kommt das Virus_.mp3", "title": "Woher kommt das Virus?", "duration": 214.968, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Wie funktioniert ein Coronatest_.mp3", "title": "Wie funktioniert ein Coronatest?", "duration": 222.096, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Welche Symptome hat man bei Covid-19_.mp3", "title": "Welche Symptome hat man bei Covid-19?", "duration": 174.288, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Was macht das Virus im Körper_.mp3", "title": "Was macht das Virus im Körper?", "duration": 244.128, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Was ist die Nies-Etikette_.mp3", "title": "Was ist die Nies-Etikette?", "duration": 204.816, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Was ist Quarantäne_.mp3", "title": "Was ist Quarantäne?", "duration": 740.568, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Was ist Desinfektion_.mp3", "title": "Was ist Desinfektion?", "duration": 237.096, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Was bringt ein Mundschutz_.mp3", "title": "Was bringt ein Mundschutz?", "duration": 167.688, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Warum hilft Händewaschen gegen Corona_.mp3", "title": "Warum hilft Händewaschen gegen Corona?", "duration": 201.36, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Warum daheim bleiben_.mp3", "title": "Warum daheim bleiben?", "duration": 130.536, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Können Haustiere das Coronavirus bekommen_.mp3", "title": "Können Haustiere das Coronavirus bekommen?", "duration": 145.968, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Kann man mehrmals an COVID-19 erkranken_.mp3", "title": "Kann man mehrmals an COVID-19 erkranken?", "duration": 245.64, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210301 - Der oder das Virus_ Und warum Corona_.mp3", "title": "Der oder das Virus? Und warum Corona?", "duration": 175.248, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210225 - Spezial - Welt-Schul-Maus.mp3", "title": "Spezial - Welt-Schul-Maus", "duration": 1439.184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210219 - Die Schule der Zukunft.mp3", "title": "Die Schule der Zukunft", "duration": 292.92, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210218 - Roboter im Krankenhaus.mp3", "title": "Roboter im Krankenhaus", "duration": 574.248, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210218 - Plastikmüll im Meer.mp3", "title": "Plastikmüll im Meer", "duration": 726.816, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210218 - Game Design.mp3", "title": "Game Design", "duration": 755.952, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210218 - Früher-Heute_ Zange.mp3", "title": "Früher-Heute: Zange", "duration": 692.808, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210218 - Früher-Heute_ Schrankenwärter.mp3", "title": "Früher-Heute: Schrankenwärter", "duration": 339.864, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210218 - Früher-Heute_ Brötchen.mp3", "title": "Früher-Heute: Brötchen", "duration": 630.192, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210218 - Cloud-Computing.mp3", "title": "Cloud-Computing", "duration": 518.832, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210215 - Wie funktioniert eine Kehrmaschine_.mp3", "title": "Wie funktioniert eine Kehrmaschine?", "duration": 367.032, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210215 - Wie funktioniert ein Salto_.mp3", "title": "Wie funktioniert ein Salto?", "duration": 276.888, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20210215 - Wie funktioniert Bogenschießen_.mp3", "title": "Wie funktioniert Bogenschießen?", "duration": 396.24, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20201129 - Adventskalender Faserguss.mp3", "title": "Adventskalender Faserguss", "duration": 607.848, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20190707 - Mikrofon-Kamera.mp3", "title": "Mikrofon-Kamera", "duration": 343.152, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Die Sendung mit der Maus/20170903 - Salz - Wie kommt es aus dem Meer_.mp3", "title": "Salz - Wie kommt es aus dem Meer?", "duration": 441.408, "analysis": null}], "files": [["20260821 - Gottesanbeterin – Wie fängt sie ihre Beute_.mp3", 6682422, 1787748051], ["20260814 - Stadtschafe -Teil 3 „Spätsommer“.mp3", 9097948, 1787748079], ["20260807 - Haare für den Umweltschutz.mp3", 10935794, 1787748078], ["20260710 - Wie wird eine Toilettenschüssel gemacht_.mp3", 12125803, 1787748109], ["20260703 - Stadtschafe - Teil 2_ Frühsommer.mp3", 8895528, 1787748109], ["20260619 - Leben auf dem Hausboot.mp3", 7384345, 1787748281], ["20260605 - Helenes neues Ohr – CI Implantat.mp3", 7615047, 1787748121], ["20260206 - Defibrillator_ Was sind das für Kästen an der Wand_.mp3", 7750106, 1787748130], ["20260130 - Schlitten aus Kunststoff.mp3", 7888207, 1787748150], ["20260123 - Druck Mindesthaltbarkeitsdatum.mp3", 7561814, 1787748154], ["20260116 - Furzstuhl.mp3", 9528962, 1787748181], ["20260109 - Gondelrettung.mp3", 7267236, 1787748194], ["20260102 - Wie funktioniert ein Schrittzähler_.mp3", 6770835, 1787748183], ["20251226 - Wie werden Tischfeuerwerk und Knallbonbons hergestellt_.mp3", 8157436, 1787748218], ["20251219 - Weihnachtsbaum aus dem 3D-Drucker.mp3", 7864385, 1787748225], ["20251128 - Kontaktlinsen.mp3", 14220771, 1787748263], ["20251121 - JVA – Welche Berufe gibt es im Gefängnis_.mp3", 24248236, 1787748340], ["20251114 - Supermarkt aus Holzbausteinen.mp3", 9230319, 1787748277], ["20251107 - Blindenfußball.mp3", 8020389, 1787748286], ["20251017 - Wie werden Baumsamen gewonnen_.mp3", 11202115, 1787748341], ["20251010 - Flexibles Wohnen.mp3", 9714352, 1787748338], ["20251003 - Apfelsaft pressen.mp3", 5318145, 1787748341], ["20250926 - Textilrecycling.mp3", 9094429, 1787748367], ["20250919 - Rollen-Herstellung.mp3", 14835893, 1787748433], ["20250912 - Radierbare Gelstifte.mp3", 6741275, 1787748397], ["20250822 - Mein Zuhause ist anders – Jolandas Baumhaus.mp3", 10609258, 1787748456], ["20250801 - Streusalz aus Gurkenwasser.mp3", 17360575, 1787748515], ["20250704 - Fassadenbegrünung.mp3", 17705663, 1787748610], ["20250627 - Update Maus-Baum 2025.mp3", 4765905, 1787748535], ["20250620 - Wie schreibt eine KI Texte_.mp3", 5775161, 1787748542], ["20250613 - Bergfeuer (Teil 1 und 2).mp3", 14402840, 1787748613], ["20250606 - Schiffshebewerk Niederfinow.mp3", 10945630, 1787748576], ["20250530 - Margeritensamen.mp3", 10102365, 1787748607], ["20250516 - Wie funktioniert der Küstenschutz auf Sylt_.mp3", 11724857, 1787748617], ["20250502 - Wofür braucht der Maikäfer seine Fühler_.mp3", 6433606, 1787748640], ["20250404 - Lauras Machgeschichten · Der Löffel-Lüfter.mp3", 8799014, 1787748686], ["20250328 - Modellauto (mit Gebärdensprache).mp3", 8341648, 1787748655], ["20250321 - Magnetresonanztomografie.mp3", 14353629, 1787748717], ["20250314 - Lauras Machgeschichten · Die Gabel-Zange.mp3", 5132210, 1787748676], ["20250307 - Zaunkönig.mp3", 7244759, 1787748698], ["20250228 - Wieso rutscht man auf einer Bananenschale aus_.mp3", 9021023, 1787748699], ["20250221 - Alex' Südpol-Mission · Teil 2.mp3", 12869981, 1787748408], ["20250214 - Alex' Südpol-Mission · Teil 1.mp3", 12409861, 1787748396], ["20250131 - Rico und Oskar · Die Flirtschule.mp3", 6366744, 1787748710], ["20250117 - Kita heben.mp3", 8565717, 1787748722], ["20250110 - Apfelplantage _ Winter.mp3", 14120255, 1787748764], ["20241227 - Raumschiffbau für die Mondmission.mp3", 19054959, 1787748769], ["20241220 - Wie wird eine Lichterkette gemacht_.mp3", 9241751, 1787748755], ["20241206 - Wie funktioniert eine Bowling-Bahn_.mp3", 7178955, 1787748754], ["20241122 - Wie funktioniert das Bundesverfassungsgericht_.mp3", 16864470, 1787748850], ["20241108 - Lauras Machgeschichten_ Das Besser-Messer.mp3", 7816461, 1787748812], ["20241101 - Wie viel Energie erzeugt ein Windrad bei einer Umdrehung_.mp3", 9557625, 1787748825], ["20241025 - Vogel auf dem Draht.mp3", 6079532, 1787748847], ["20241018 - Klodeckel mit Absenkautomatik.mp3", 7869277, 1787748845], ["20241011 - Apfelplantage Teil 3_ Herbst.mp3", 12851941, 1787748898], ["20241005 - Harrys Sprachmobil.mp3", 5744443, 1787748884], ["20240929 - Kaffeebecher aus Kaffeesatz.mp3", 21111251, 1787748940], ["20240927 - Ein Quadratmeter Natur_ Rheinufer.mp3", 7758108, 1787748901], ["20240920 - Seltene Erde.mp3", 7765438, 1787748900], ["20240913 - Pfadfinder.mp3", 11646717, 1787748958], ["20240906 - Ampelschaltung.mp3", 7865539, 1787748937], ["20240830 - Solidarische Landwirtschaft.mp3", 10804883, 1787748951], ["20240823 - Esel - Wie ist er zum Haustier geworden_.mp3", 6313442, 1787748971], ["20240816 - Blobben mit André.mp3", 5321189, 1787748954], ["20240809 - Türklinke.mp3", 7966764, 1787749004], ["20240719 - Johannes Olympia Top Ten.mp3", 10665748, 1787749018], ["20240712 - Horizontalbohrer.mp3", 8294214, 1787748984], ["20240705 - Lupinen-Schnitzel.mp3", 8072847, 1787749007], ["20240628 - Apfelplantage_ Sommer.mp3", 10982975, 1787749046], ["20240621 - Ameisenlöwe_ Wie fängt er seine Beute_ (mit Gebärdensprache).mp3", 7869310, 1787749040], ["20240614 - Immer geradeaus.mp3", 7708978, 1787749050], ["20240607 - Was ist eine Flugzeugdrohne_.mp3", 8461566, 1787749046], ["20240531 - Kraftstoff aus Frittenfett.mp3", 14509115, 1787749122], ["20240524 - Sachgeschichte_ Longboard.mp3", 9239025, 1787749100], ["20240517 - Sachgeschichte_ Warum hält der Neoprenanzug warm_.mp3", 11949874, 1787749118], ["20240510 - Sachgeschichte_ Regentropfen.mp3", 6698171, 1787749076], ["20240409 - Können Fische hören_.mp3", 7020808, 1787749094], ["20240407 - Wie wird ein Wald aufgeforstet_.mp3", 9089341, 1787749129], ["20240407 - Wie pinkeln Vögel_.mp3", 6571049, 1787749119], ["20240324 - Woher kommen unsere Schuhgrößen_.mp3", 12185188, 1787749214], ["20240321 - Ostereierfarben.mp3", 6168943, 1787749663], ["20240321 - Bunte Eier.mp3", 3231443, 1787749130], ["20240228 - Was ist eine Kartoffelpflanzmaschine_.mp3", 4744396, 1787749150], ["20240225 - Mona und ihre seltene Krankheit.mp3", 23404561, 1787749266], ["20240204 - Sachgeschichte_ Eisbärfigur.mp3", 5849324, 1787749157], ["20240123 - Wie öffnet sich ein Kiefernzapfen_.mp3", 6330607, 1787749179], ["20240122 - Wie wird das Maus-Krätzchen genäht_.mp3", 6629385, 1787749203], ["20240117 - Wie entsteht ein Drehwurm_.mp3", 12585226, 1787749253], ["20240106 - Wie sieht ein CO2-Molekül aus_.mp3", 10018230, 1787749233], ["20240105 - Wie wird Haferdrink hergestellt_.mp3", 6838593, 1787749249], ["20240105 - Wie werden Wendepailletten angenäht_.mp3", 8949968, 1787749263], ["20240105 - Wie funktioniert ein Seifenspender_.mp3", 7402539, 1787749235], ["20231228 - Wie wird ein riesiger Schneemann gebaut_.mp3", 12503102, 1787749301], ["20231205 - Warum fällen Biber Bäume_.mp3", 8111179, 1787749304], ["20231116 - Weihnachtsfrucht.mp3", 3469369, 1787749305], ["20231116 - Weihnachtsbaum schmückt sich von selbst.mp3", 3696758, 1787749285], ["20231116 - Lauras Machgeschichte_ Pinguin mit Keksversteck.mp3", 6581724, 1787749291], ["20231110 - Leverkusener Autobahnbrücke - Folge 7.mp3", 11458320, 1787749349], ["20231025 - Leverkusener Autobahnbrücke (Teil 6).mp3", 7260954, 1787749341], ["20231025 - Leverkusener Autobahnbrücke (Teil 3).mp3", 11172449, 1787749341], ["20231025 - Leverkusener Autobahnbrücke (Teil 2).mp3", 12660096, 1787749352], ["20231023 - Wie wächst das Hirschgeweih_.mp3", 7465378, 1787749393], ["20231023 - Papiertaschentücher.mp3", 8130231, 1787749378], ["20231023 - Laubkompost.mp3", 6919890, 1787749376], ["20231023 - Blattfarben.mp3", 7042011, 1787748315], ["20230915 - Rheinkrake.mp3", 12987076, 1787749420], ["20230914 - Lauras Machgeschichten · Leuchtender Regenschirm.mp3", 6159765, 1787749400], ["20230913 - Zauberstifte.mp3", 5227126, 1787749389], ["20230913 - Schulkantine.mp3", 12316059, 1787749450], ["20230913 - Früher-Heute-Geschichten_ Schulstuhl.mp3", 9991538, 1787749444], ["20230825 - Ein Quadratmeter Natur – Landschaftspark Nord.mp3", 7488955, 1787748059], ["20230816 - Lauras Machgeschichten_ Zuckerwatte-Maschine.mp3", 8885295, 1787749457], ["20230816 - Lauras Machgeschichten_ Stein-Flitsch-Maschine.mp3", 9521465, 1787749472], ["20230816 - Lauras Machgeschichten_ Autospritzdüse wird Quatschkamera.mp3", 7533825, 1787749486], ["20230804 - Wie funktioniert Freistoß-Spray_.mp3", 7783247, 1787749502], ["20230803 - Wie lebt ein Feuersalamander_.mp3", 7610451, 1787749501], ["20230629 - Sachgeschichte_ Marokko-Maus Teil 4b.mp3", 9094190, 1787749523], ["20230629 - Sachgeschichte_ Marokko-Maus Teil 4a.mp3", 5607346, 1787749501], ["20230629 - Sachgeschichte_ Marokko-Maus Teil 3a.mp3", 5673960, 1787749540], ["20230629 - Sachgeschichte_ Marokko-Maus Teil 2b.mp3", 7439437, 1787749548], ["20230629 - Sachgeschichte_ Marokko-Maus Teil 2a.mp3", 6477135, 1787749548], ["20230629 - Sachgeschichte_ Marokko-Maus Teil 1a.mp3", 6541978, 1787749544], ["20230620 - Eis am Stiel.mp3", 8270603, 1787749573], ["20230619 - Warum wollen Wespen unser Essen_.mp3", 6956839, 1787749574], ["20230618 - Lauras Machgeschichten_ Hängematte ohne Baum.mp3", 7481551, 1787749571], ["20230617 - Kann man Metall mit Wasser schneiden_.mp3", 6343446, 1787749579], ["20230523 - Sachgeschichte_ Olivia, Willi und die Planeten.mp3", 2482474, 1787749580], ["20230523 - Sachgeschichte_ Autistin Luzi.mp3", 7881764, 1787749607], ["20230514 - Wildbienen.mp3", 7164945, 1787749615], ["20230427 - Lachgeschichte_ Kraftmacher–Lied für alle _Niemals dran gezweifelt_.mp3", 3999779, 1787749591], ["20230419 - Haifischhautfolie.mp3", 15293230, 1787749661], ["20230206 - Jeck Yeah.mp3", 4001582, 1787749629], ["20230206 - Alemannische Fasnet.mp3", 6904645, 1787749623], ["20221202 - Walnuss.mp3", 8386994, 1787748231], ["20220718 - Sonnenmilch.mp3", 6012353, 1787749640], ["20220603 - Roboter-Bagger.mp3", 4838847, 1787748492], ["20220408 - Helikopter Windkraftanlagen Reparatur auf hoher See.mp3", 13563797, 1787748503], ["20220403 - Ich bin Katja.mp3", 14327543, 1787749685], ["20220316 - Was ist ein Teichrasierer_.mp3", 6512622, 1787749671], ["20220311 - Geigenbogen.mp3", 10080810, 1787748665], ["20220308 - Wie läuft der Wasserläufer übers Wasser_.mp3", 5924699, 1787748429], ["20220308 - Wie lässt man Steine hüpfen_.mp3", 5857188, 1787749679], ["20220302 - Was ist Aquaponik_.mp3", 9103897, 1787749702], ["20220301 - Wie funktioniert ein Pumpspeicherwerk_.mp3", 10037823, 1787749719], ["20220127 - Sachgeschichte_ Bobbahn.mp3", 13849730, 1787749751], ["20220116 - Wie wird eine Europalette hergestellt_.mp3", 8572116, 1787749717], ["20220116 - Wie kommen Fahrbahnstreifen auf die Straße_.mp3", 12119304, 1787749719], ["20220115 - Kardanische Aufhängung.mp3", 8592113, 1787748434], ["20220109 - Was ist LRS_.mp3", 9154704, 1787749741], ["20211220 - Was ist ein Laser_.mp3", 10754751, 1787749741], ["20211220 - Mausprojektion_ Wie kommt die Maus aufs Archivhaus_.mp3", 7946026, 1787749754], ["20211215 - Weihnachtsgeschenkpapier.mp3", 8050235, 1787749777], ["20211215 - Lebensmittelfarbe.mp3", 6724581, 1787749766], ["20211203 - Sachgeschichte_ LED.mp3", 8337457, 1787749772], ["20211012 - Tiba_ Musikunterricht.mp3", 7477741, 1787749788], ["20211012 - Tiba_ Erster Schultag.mp3", 8946714, 1787749801], ["20211012 - Tiba_ Die Entscheidung.mp3", 5708998, 1787749791], ["20211012 - Tiba_ Beim Augenarzt.mp3", 8498629, 1787749807], ["20211001 - Brillen aus Wunderbohnen.mp3", 10338172, 1787749825], ["20210813 - Urban Mining (Teil 4).mp3", 12607252, 1787749839], ["20210813 - Urban Mining (Teil 3).mp3", 13908244, 1787749854], ["20210813 - Urban Mining (Teil 2).mp3", 12316180, 1787749853], ["20210813 - Urban Mining (Teil 2 mit Gebärdensprache).mp3", 12316201, 1787749845], ["20210813 - Urban Mining (Teil 1).mp3", 16051732, 1787749888], ["20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 6.mp3", 9577000, 1787749879], ["20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 5.mp3", 9797213, 1787749885], ["20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 4.mp3", 10851556, 1787749896], ["20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 3.mp3", 9927656, 1787749928], ["20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 2.mp3", 8870399, 1787749927], ["20210812 - Sachgeschichte_ Wanderweg der deutschen Einheit Teil 1.mp3", 10479180, 1787749933], ["20210809 - Berliner Mauer.mp3", 7605254, 1787749926], ["20210608 - Stadionsitz.mp3", 9107347, 1787749953], ["20210606 - Warum ist der Apollofalter so selten_.mp3", 6581869, 1787748534], ["20210603 - Brille für Afrika.mp3", 7763480, 1787749949], ["20210603 - Blindenschule.mp3", 26895274, 1787750031], ["20210603 - Blindenschrift.mp3", 5906550, 1787749944], ["20210527 - Flamingoschnabel.mp3", 7180400, 1787748637], ["20210509 - Sachgeschichte_ Herzsymbol.mp3", 6661168, 1787749971], ["20210430 - So funktioniert Urban Gardening.mp3", 7455326, 1787749978], ["20210430 - Ein Küken kommt aus dem Ei.mp3", 12688436, 1787749990], ["20210307 - Wie hält ein Torwart sein Tor sauber_.mp3", 5683609, 1787748080], ["20210307 - Seepferdchen.mp3", 6739785, 1787748627], ["20210307 - Lachgeschichte_ Planet Willi.mp3", 7657020, 1787750009], ["20210307 - Der Muffin Code.mp3", 5747639, 1787750006], ["20210303 - Suchmaschine.mp3", 8455857, 1787750055], ["20210301 - Überträgt sich das Virus über Gegenstände_.mp3", 2797535, 1787750080], ["20210301 - Woher kommt das Virus_.mp3", 3440238, 1787750128], ["20210301 - Wie funktioniert ein Coronatest_.mp3", 3554353, 1787750103], ["20210301 - Welche Symptome hat man bei Covid-19_.mp3", 2789331, 1787750137], ["20210301 - Was macht das Virus im Körper_.mp3", 3906687, 1787750149], ["20210301 - Was ist die Nies-Etikette_.mp3", 3277940, 1787750126], ["20210301 - Was ist Quarantäne_.mp3", 11849858, 1787750182], ["20210301 - Was ist Desinfektion_.mp3", 3794344, 1787750102], ["20210301 - Was bringt ein Mundschutz_.mp3", 2683797, 1787750112], ["20210301 - Warum hilft Händewaschen gegen Corona_.mp3", 3222541, 1787750164], ["20210301 - Warum daheim bleiben_.mp3", 2089449, 1787750157], ["20210301 - Können Haustiere das Coronavirus bekommen_.mp3", 2336301, 1787750088], ["20210301 - Kann man mehrmals an COVID-19 erkranken_.mp3", 3931086, 1787750111], ["20210301 - Der oder das Virus_ Und warum Corona_.mp3", 2804720, 1787750143], ["20210225 - Spezial - Welt-Schul-Maus.mp3", 23027640, 1787750198], ["20210219 - Die Schule der Zukunft.mp3", 4687363, 1787750175], ["20210218 - Roboter im Krankenhaus.mp3", 9188723, 1787750051], ["20210218 - Plastikmüll im Meer.mp3", 11630052, 1787750092], ["20210218 - Game Design.mp3", 12095972, 1787750056], ["20210218 - Früher-Heute_ Zange.mp3", 11085696, 1787750041], ["20210218 - Früher-Heute_ Schrankenwärter.mp3", 5438995, 1787750025], ["20210218 - Früher-Heute_ Brötchen.mp3", 10084431, 1787750020], ["20210218 - Cloud-Computing.mp3", 8301961, 1787748655], ["20210215 - Wie funktioniert eine Kehrmaschine_.mp3", 5873235, 1787750069], ["20210215 - Wie funktioniert ein Salto_.mp3", 4430887, 1787750070], ["20210215 - Wie funktioniert Bogenschießen_.mp3", 6340784, 1787750084], ["20201129 - Adventskalender Faserguss.mp3", 9726475, 1787748820], ["20190707 - Mikrofon-Kamera.mp3", 5491168, 1787748460], ["20170903 - Salz - Wie kommt es aus dem Meer_.mp3", 7063272, 1787748489]]}, {"id": "e95134365b97", "section": "Kinderpodcasts", "kind": "book", "title": "GEOlino Spezial", "artist": "GEOlino Spezial", "series": "GEOlino Spezial", "figure": null, "colors": ["#66c113", "#60423d", "#b68141"], "folder": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial", "cover": "/home/martin/code/musicmouse/python-backend/.musicmouse-cache/covers/e95134365b97.jpg", "tracks": [{"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260805 - Zu Besuch auf einem Gandenhof.mp3", "title": "Zu Besuch auf einem Gandenhof", "duration": 1270.3869387755103, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260701 - Jaguare und Geparden.mp3", "title": "Jaguare und Geparden", "duration": 1253.4595918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260603 - Katzen.mp3", "title": "Katzen", "duration": 1537.4367346938775, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260506 - Azteken.mp3", "title": "Azteken", "duration": 1813.5248979591836, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260401 - Inka.mp3", "title": "Inka", "duration": 1676.2514285714285, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260304 - Maya.mp3", "title": "Maya", "duration": 1663.0073469387755, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260204 - Pubertät.mp3", "title": "Pubertät", "duration": 977.0318367346939, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20260107 - Unsere Sinne.mp3", "title": "Unsere Sinne", "duration": 1162.945306122449, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20251203 - Aktive Körper.mp3", "title": "Aktive Körper", "duration": 1168.7706122448978, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20251105 - Intelligente Roboter.mp3", "title": "Intelligente Roboter", "duration": 1054.458775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20251022 - Tierische Erfindungen.mp3", "title": "Tierische Erfindungen", "duration": 1148.7085714285715, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20251008 - Leonardo da Vinci.mp3", "title": "Leonardo da Vinci", "duration": 1405.309387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250924 - Gigantisch groß.mp3", "title": "Gigantisch groß", "duration": 1525.1069387755101, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250910 - Dinosaurier.mp3", "title": "Dinosaurier", "duration": 1291.4416326530613, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250827 - Die Götter der Griechen.mp3", "title": "Die Götter der Griechen", "duration": 1086.3020408163266, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250813 - Alexander der Große.mp3", "title": "Alexander der Große", "duration": 1019.7420408163265, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250730 - Wissenschaft im antiken Griechenland.mp3", "title": "Wissenschaft im antiken Griechenland", "duration": 928.1567346938775, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250716 - Sparta!.mp3", "title": "Sparta!", "duration": 1093.3289795918367, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250702 - Alarmtiere.mp3", "title": "Alarmtiere", "duration": 1118.667755102041, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250618 - Marianengraben.mp3", "title": "Marianengraben", "duration": 1139.722448979592, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250528 - Krachende Kontinente.mp3", "title": "Krachende Kontinente", "duration": 1102.4457142857143, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250514 - Unsere Erde.mp3", "title": "Unsere Erde", "duration": 867.5526530612245, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250430 - Vorurteile.mp3", "title": "Vorurteile", "duration": 1859.3436734693878, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250416 - Das ist Familie.mp3", "title": "Das ist Familie", "duration": 1252.8065306122448, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250402 - Buntes Deutschland.mp3", "title": "Buntes Deutschland", "duration": 749.8971428571429, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250319 - Teufel aus der Tiefe.mp3", "title": "Teufel aus der Tiefe", "duration": 1067.1020408163265, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250305 - Wölfe.mp3", "title": "Wölfe", "duration": 776.1240816326531, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250219 - Sonderfolge_ Die Wahl.mp3", "title": "Sonderfolge: Die Wahl", "duration": 1164.0685714285714, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250212 - Sonderfolge_ Kinder an der Macht.mp3", "title": "Sonderfolge: Kinder an der Macht", "duration": 1194.945306122449, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250205 - Sonderfolge_ Demokratie.mp3", "title": "Sonderfolge: Demokratie", "duration": 1318.0865306122448, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250122 - Löwen.mp3", "title": "Löwen", "duration": 692.2710204081633, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20250108 - Raubtiere.mp3", "title": "Raubtiere", "duration": 908.1991836734694, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20241225 - Das alte Ägypten_ Im Reich der Toten.mp3", "title": "Das alte Ägypten: Im Reich der Toten", "duration": 884.2187755102041, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20241211 - Alltag im alten Ägypten.mp3", "title": "Alltag im alten Ägypten", "duration": 1248.0, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20241127 - Das alte Ägypten_ Hieroglyphen.mp3", "title": "Das alte Ägypten: Hieroglyphen", "duration": 988.5779591836734, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20241113 - Das alte Ägypten_ Pyramiden.mp3", "title": "Das alte Ägypten: Pyramiden", "duration": 1363.905306122449, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20241030 - Die US-Wahl.mp3", "title": "Die US-Wahl", "duration": 1973.2114285714285, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20241016 - Mammutbäume.mp3", "title": "Mammutbäume", "duration": 758.8571428571429, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20241002 - Die Geschichte der USA – Teil 2.mp3", "title": "Die Geschichte der USA – Teil 2", "duration": 1266.2595918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240918 - Die Geschichte der USA – Teil 1.mp3", "title": "Die Geschichte der USA – Teil 1", "duration": 1252.9110204081633, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240904 - Ötzi, der Mann aus dem Eis.mp3", "title": "Ötzi, der Mann aus dem Eis", "duration": 840.9338775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240821 - Tierische Ermittler.mp3", "title": "Tierische Ermittler", "duration": 1154.6383673469388, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240807 - Spurensicherung.mp3", "title": "Spurensicherung", "duration": 981.4726530612245, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240724 - Cybermobbing.mp3", "title": "Cybermobbing", "duration": 1068.094693877551, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240710 - Olympia.mp3", "title": "Olympia", "duration": 1097.0644897959185, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240626 - Nationalflaggen.mp3", "title": "Nationalflaggen", "duration": 968.3330612244898, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240612 - Fußball.mp3", "title": "Fußball", "duration": 789.3942857142857, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240529 - Sport.mp3", "title": "Sport", "duration": 807.0530612244899, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240515 - Raketen im Regenwald.mp3", "title": "Raketen im Regenwald", "duration": 1094.6612244897958, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240501 - Giftige Tiere.mp3", "title": "Giftige Tiere", "duration": 853.9428571428572, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240417 - Amazonas_ Vom Boden bis zur Baumkrone.mp3", "title": "Amazonas: Vom Boden bis zur Baumkrone", "duration": 912.509387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240403 - Regenwald.mp3", "title": "Regenwald", "duration": 908.8522448979592, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240320 - Burgen im Mittelalter.mp3", "title": "Burgen im Mittelalter", "duration": 1149.6751020408162, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240306 - Mittelalter.mp3", "title": "Mittelalter", "duration": 1301.0808163265306, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240221 - Roboter.mp3", "title": "Roboter", "duration": 1294.9420408163264, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240207 - Brauchen wir Regeln für KI_.mp3", "title": "Brauchen wir Regeln für KI?", "duration": 938.2661224489796, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240124 - Künstliche Intelligenz.mp3", "title": "Künstliche Intelligenz", "duration": 910.6808163265306, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20240110 - Außerirdisches Leben.mp3", "title": "Außerirdisches Leben", "duration": 1073.2930612244897, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20231227 - Das Universum.mp3", "title": "Das Universum", "duration": 1244.16, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20231213 - Stern von Bethlehem.mp3", "title": "Stern von Bethlehem", "duration": 828.029387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20231129 - Tutanchamun.mp3", "title": "Tutanchamun", "duration": 1127.7844897959183, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20231115 - Archäologie.mp3", "title": "Archäologie", "duration": 919.3795918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20231101 - Geschwister.mp3", "title": "Geschwister", "duration": 1027.2914285714285, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20231018 - Eine elefantastische Familie.mp3", "title": "Eine elefantastische Familie", "duration": 914.0767346938776, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20231004 - Gene und Vererbung.mp3", "title": "Gene und Vererbung", "duration": 1322.109387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230920 - Du bist Familie.mp3", "title": "Du bist Familie", "duration": 1120.8620408163265, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230906 - Welt ohne Insekten.mp3", "title": "Welt ohne Insekten", "duration": 764.9175510204082, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230823 - Heuschrecken.mp3", "title": "Heuschrecken", "duration": 926.9551020408163, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230809 - Das Superinsekt_ Ameisen.mp3", "title": "Das Superinsekt: Ameisen", "duration": 794.0963265306123, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230726 - Wut und Hoffnung_ _Fridays for Future_.mp3", "title": "Wut und Hoffnung: \"Fridays for Future\"", "duration": 808.7510204081633, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230712 - Klimawandel in der ganzen Welt.mp3", "title": "Klimawandel in der ganzen Welt", "duration": 743.6538775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230628 - Klimawandel in Deutschland.mp3", "title": "Klimawandel in Deutschland", "duration": 859.010612244898, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230614 - Edelweißpiraten_ Gegen Hitler kämpfen.mp3", "title": "Edelweißpiraten: Gegen Hitler kämpfen", "duration": 913.8938775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230531 - Die Nazis_ Hitler war ein Mensch.mp3", "title": "Die Nazis: Hitler war ein Mensch", "duration": 999.9151020408164, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230517 - Feinde der Nazis_ Die wilden Cliquen in Berlin.mp3", "title": "Feinde der Nazis: Die wilden Cliquen in Berlin", "duration": 729.3126530612245, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230503 - Geschichte - das bist Du!.mp3", "title": "Geschichte - das bist Du!", "duration": 940.3820408163265, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230419 - Wal gegen Mensch_ Wer ist das Monster_.mp3", "title": "Wal gegen Mensch: Wer ist das Monster?", "duration": 829.3355102040816, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230412 - Wale - die größten Sänger der Welt.mp3", "title": "Wale - die größten Sänger der Welt", "duration": 841.7436734693878, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230405 - Vaquitas - die kleinsten Wale der Welt.mp3", "title": "Vaquitas - die kleinsten Wale der Welt", "duration": 969.4302040816326, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230329 - _I have a dream_.mp3", "title": "\"I have a dream\"", "duration": 1211.8726530612246, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230322 - Winterschlaf im Weltraum.mp3", "title": "Winterschlaf im Weltraum", "duration": 878.4457142857143, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230315 - Das ist ja zum Einschlafen!.mp3", "title": "Das ist ja zum Einschlafen!", "duration": 863.5297959183673, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230308 - Sag mal, träumst Du_.mp3", "title": "Sag mal, träumst Du?", "duration": 1336.7118367346939, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230301 - Inuk heißt Mensch.mp3", "title": "Inuk heißt Mensch", "duration": 1009.92, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230222 - Shackleton.mp3", "title": "Shackleton", "duration": 1169.6326530612246, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230215 - Wer hat Angst vorm Walross_.mp3", "title": "Wer hat Angst vorm Walross?", "duration": 964.414693877551, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20230208 - Kälte ist cool.mp3", "title": "Kälte ist cool", "duration": 937.0383673469388, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221221 - Weihnachten_ Jetzt wird gebacken!.mp3", "title": "Weihnachten: Jetzt wird gebacken!", "duration": 1549.0873469387755, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221214 - Gab es Jesus wirklich_.mp3", "title": "Gab es Jesus wirklich?", "duration": 1469.1004081632652, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221207 - Wie Weihnachten klingt.mp3", "title": "Wie Weihnachten klingt", "duration": 1041.0057142857142, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221130 - Schokolade, Gefühle und Weihnachten.mp3", "title": "Schokolade, Gefühle und Weihnachten", "duration": 986.1485714285715, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221123 - Kinderrechte_ Reise durch die Zeit.mp3", "title": "Kinderrechte: Reise durch die Zeit", "duration": 1118.6938775510205, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221116 - Kinderrechte_ Das könnt ihr tun!.mp3", "title": "Kinderrechte: Das könnt ihr tun!", "duration": 880.9795918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221109 - Kinderrechte_ Euer Tag mit Rapper DIKKA.mp3", "title": "Kinderrechte: Euer Tag mit Rapper DIKKA", "duration": 641.9330612244898, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221102 - Kinderrechte.mp3", "title": "Kinderrechte", "duration": 939.389387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221026 - Schneeleoparden.mp3", "title": "Schneeleoparden", "duration": 796.5257142857142, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221019 - Gletscher.mp3", "title": "Gletscher", "duration": 593.7110204081632, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221012 - Vulkane.mp3", "title": "Vulkane", "duration": 866.4816326530612, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20221005 - Berge.mp3", "title": "Berge", "duration": 648.9338775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220928 - Echte Verbrechen_ Fälschungen.mp3", "title": "Echte Verbrechen: Fälschungen", "duration": 823.1183673469387, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220921 - Echte Verbrechen_ Hochstapler.mp3", "title": "Echte Verbrechen: Hochstapler", "duration": 1036.8261224489795, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220914 - Echte Verbrechen_ Raub im Regenwald.mp3", "title": "Echte Verbrechen: Raub im Regenwald", "duration": 942.8897959183673, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220907 - Echte Verbrechen_ Die verschwundene Violine.mp3", "title": "Echte Verbrechen: Die verschwundene Violine", "duration": 916.8979591836735, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220831 - Laub.mp3", "title": "Laub", "duration": 697.0514285714286, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220824 - Hirsche.mp3", "title": "Hirsche", "duration": 1095.2881632653061, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220817 - Das Wald-Internet.mp3", "title": "Das Wald-Internet", "duration": 819.7224489795918, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220810 - Wie wir den Wald retten.mp3", "title": "Wie wir den Wald retten", "duration": 1251.9183673469388, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220803 - Unser Wald.mp3", "title": "Unser Wald", "duration": 1009.8155102040816, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220615 - Sprache.mp3", "title": "Sprache", "duration": 875.52, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220608 - Bakterien.mp3", "title": "Bakterien", "duration": 849.2669387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220601 - Fantasie.mp3", "title": "Fantasie", "duration": 890.9322448979592, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220525 - Luft.mp3", "title": "Luft", "duration": 870.7657142857142, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220517 - Unsichtbare Welten.mp3", "title": "Unsichtbare Welten", "duration": 966.9224489795919, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220511 - Tierisch schlau_ Raben.mp3", "title": "Tierisch schlau: Raben", "duration": 761.8873469387755, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220504 - Tierisch schlau_ Tierfamilien.mp3", "title": "Tierisch schlau: Tierfamilien", "duration": 832.3918367346939, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220427 - Tierisch schlau_ Kraken.mp3", "title": "Tierisch schlau: Kraken", "duration": 780.8261224489796, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220420 - Tierisch schlau_ Intelligenz der Tiere.mp3", "title": "Tierisch schlau: Intelligenz der Tiere", "duration": 960.9142857142857, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220413 - Raub im Kinosaal.mp3", "title": "Raub im Kinosaal", "duration": 950.8310204081632, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220406 - Walt Disney.mp3", "title": "Walt Disney", "duration": 1404.16, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220330 - Fernsehen.mp3", "title": "Fernsehen", "duration": 1052.4734693877551, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220323 - Die Welt des Films.mp3", "title": "Die Welt des Films", "duration": 997.2767346938775, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220316 - Krieg in der Ukraine_ Wie können wir helfen_.mp3", "title": "Krieg in der Ukraine: Wie können wir helfen?", "duration": 1512.698775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220309 - Krieg in der Ukraine_ Was stimmt, was stimmt nicht_.mp3", "title": "Krieg in der Ukraine: Was stimmt, was stimmt nicht?", "duration": 2155.206530612245, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220302 - Krieg in der Ukraine_ Was ist passiert_.mp3", "title": "Krieg in der Ukraine: Was ist passiert?", "duration": 1373.0220408163266, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220223 - Helden im Tierreich.mp3", "title": "Helden im Tierreich", "duration": 1056.9926530612245, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220216 - Superhelden.mp3", "title": "Superhelden", "duration": 1115.8987755102041, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220209 - Zivilcourage.mp3", "title": "Zivilcourage", "duration": 584.2546938775511, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20220202 - Von Heldinnen und Helden.mp3", "title": "Von Heldinnen und Helden", "duration": 1116.6563265306122, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211222 - Gold.mp3", "title": "Gold", "duration": 1067.7289795918368, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211215 - Die sieben Naturwunder.mp3", "title": "Die sieben Naturwunder", "duration": 821.3681632653061, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211208 - Die Geschichte Afrikas.mp3", "title": "Die Geschichte Afrikas", "duration": 1246.6155102040816, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211201 - Afrika.mp3", "title": "Afrika", "duration": 1026.9518367346939, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211124 - Woraus unsere Welt besteht.mp3", "title": "Woraus unsere Welt besteht", "duration": 825.5477551020408, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211117 - Physik im Tierreich.mp3", "title": "Physik im Tierreich", "duration": 856.6334693877551, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211110 - Die Schwerkraft.mp3", "title": "Die Schwerkraft", "duration": 1164.8522448979593, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211103 - Rätsel des Alltags.mp3", "title": "Rätsel des Alltags", "duration": 692.0620408163265, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211027 - Die Sagen der Wikinger.mp3", "title": "Die Sagen der Wikinger", "duration": 826.0179591836735, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211020 - Der Handel der Wikinger.mp3", "title": "Der Handel der Wikinger", "duration": 1331.4351020408162, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211013 - Die Reisen der Wikinger.mp3", "title": "Die Reisen der Wikinger", "duration": 1099.4938775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20211006 - Die Raubzüge der Wikinger.mp3", "title": "Die Raubzüge der Wikinger", "duration": 724.2971428571428, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210929 - Demokratie im Tierreich.mp3", "title": "Demokratie im Tierreich", "duration": 792.5812244897959, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210922 - Demokratie_ Die Wahl.mp3", "title": "Demokratie: Die Wahl", "duration": 1119.6342857142856, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210915 - Demokratie_ Kinder an der Macht.mp3", "title": "Demokratie: Kinder an der Macht", "duration": 1296.4310204081632, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210908 - Demokratie.mp3", "title": "Demokratie", "duration": 1102.4979591836734, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210901 - Wie wir die Welt retten_ Tiertafel.mp3", "title": "Wie wir die Welt retten: Tiertafel", "duration": 833.2016326530612, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210825 - Wie wir die Welt retten_ Haare.mp3", "title": "Wie wir die Welt retten: Haare", "duration": 1017.9918367346938, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210818 - Wie wir die Welt retten_ Lob.mp3", "title": "Wie wir die Welt retten: Lob", "duration": 708.8065306122448, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210811 - Wie wir die Welt retten_ Bienen.mp3", "title": "Wie wir die Welt retten: Bienen", "duration": 1105.7632653061225, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210804 - Artenschutz vor unserer Haustür.mp3", "title": "Artenschutz vor unserer Haustür", "duration": 1548.8261224489795, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210728 - Bedrohte Arten_ Lichtverschmutzung.mp3", "title": "Bedrohte Arten: Lichtverschmutzung", "duration": 1208.8685714285714, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210721 - Bedrohte Arten_ Haie.mp3", "title": "Bedrohte Arten: Haie", "duration": 746.684081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210714 - Bedrohte Arten.mp3", "title": "Bedrohte Arten", "duration": 1373.2310204081632, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210707 - Sonne.mp3", "title": "Sonne", "duration": 653.6097959183674, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210630 - Sand.mp3", "title": "Sand", "duration": 775.2881632653061, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210519 - Musik_ Eure Fragen an Deine Freunde.mp3", "title": "Musik: Eure Fragen an Deine Freunde", "duration": 1127.9673469387756, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210512 - Musik_ Applaus.mp3", "title": "Musik: Applaus", "duration": 1059.0563265306123, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210505 - Musik im Tierreich.mp3", "title": "Musik im Tierreich", "duration": 942.8114285714286, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210428 - Musik.mp3", "title": "Musik", "duration": 1098.292244897959, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210421 - Unser Körper_ Haut.mp3", "title": "Unser Körper: Haut", "duration": 639.2163265306123, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210415 - Unsere Körpergeräusche.mp3", "title": "Unsere Körpergeräusche", "duration": 729.861224489796, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210407 - Unser Körper_ Knochen.mp3", "title": "Unser Körper: Knochen", "duration": 944.64, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210331 - Unser Körper.mp3", "title": "Unser Körper", "duration": 808.0457142857143, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210324 - Erfindungen_ Louis Braille.mp3", "title": "Erfindungen: Louis Braille", "duration": 1129.5085714285715, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210316 - Erfinden – so geht´s!.mp3", "title": "Erfinden – so geht´s!", "duration": 1052.029387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210310 - Erfindungen_ Ideen-Klau.mp3", "title": "Erfindungen: Ideen-Klau", "duration": 819.5134693877551, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210303 - Erfindungen.mp3", "title": "Erfindungen", "duration": 656.2220408163265, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210224 - Eiszeit_ Mammuts.mp3", "title": "Eiszeit: Mammuts", "duration": 898.7951020408163, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210217 - Eiszeit_ Forschung.mp3", "title": "Eiszeit: Forschung", "duration": 923.010612244898, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210210 - Eiszeit_ Leben in der Eiszeit.mp3", "title": "Eiszeit: Leben in der Eiszeit", "duration": 1035.572244897959, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20210203 - Eiszeit.mp3", "title": "Eiszeit", "duration": 728.1632653061224, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201216 - Weihnachten.mp3", "title": "Weihnachten", "duration": 1169.2408163265306, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201209 - Wintersport.mp3", "title": "Wintersport", "duration": 998.269387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201202 - Feuer.mp3", "title": "Feuer", "duration": 665.9395918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201125 - Frieren.mp3", "title": "Frieren", "duration": 693.1591836734694, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201118 - Säugetiere_ Milch.mp3", "title": "Säugetiere: Milch", "duration": 744.3069387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201111 - Säugetiere_ Verrückte Arten.mp3", "title": "Säugetiere: Verrückte Arten", "duration": 842.1877551020408, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201104 - Säugetiere_ Elefanten.mp3", "title": "Säugetiere: Elefanten", "duration": 703.738775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201028 - Säugetiere.mp3", "title": "Säugetiere", "duration": 1082.9061224489797, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201021 - Das alte Rom_ Gladiatoren.mp3", "title": "Das alte Rom: Gladiatoren", "duration": 974.08, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201014 - Das alte Rom_ Leben im alten Rom.mp3", "title": "Das alte Rom: Leben im alten Rom", "duration": 1288.9338775510205, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20201007 - Das alte Rom_ Caesar.mp3", "title": "Das alte Rom: Caesar", "duration": 921.3126530612245, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200930 - Das alte Rom.mp3", "title": "Das alte Rom", "duration": 1003.2848979591837, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200923 - Weltall_ Satelliten.mp3", "title": "Weltall: Satelliten", "duration": 739.3175510204081, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200916 - Weltall_ Mars.mp3", "title": "Weltall: Mars", "duration": 877.7404081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200909 - Weltall_ Raumfahrt.mp3", "title": "Weltall: Raumfahrt", "duration": 1080.6334693877552, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200902 - Weltall.mp3", "title": "Weltall", "duration": 852.924081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200826 - Ozeane_ Plastikmüll.mp3", "title": "Ozeane: Plastikmüll", "duration": 892.2644897959184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200819 - Ozeane_ Forschung.mp3", "title": "Ozeane: Forschung", "duration": 820.6889795918368, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200812 - Ozeane_ Wale.mp3", "title": "Ozeane: Wale", "duration": 632.5812244897959, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200805 - Ozeane.mp3", "title": "Ozeane", "duration": 885.2897959183673, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200624 - Helfen.mp3", "title": "Helfen", "duration": 849.7110204081632, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200617 - Expedition ins Eis.mp3", "title": "Expedition ins Eis", "duration": 811.1804081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200610 - Lachen.mp3", "title": "Lachen", "duration": 1124.6759183673469, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200603 - Fußball.mp3", "title": "Fußball", "duration": 681.2734693877551, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200527 - Smartphone.mp3", "title": "Smartphone", "duration": 1105.7110204081632, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200520 - Wasser.mp3", "title": "Wasser", "duration": 625.5281632653061, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200513 - Zeit.mp3", "title": "Zeit", "duration": 813.6097959183674, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200506 - Haustiere.mp3", "title": "Haustiere", "duration": 833.4628571428572, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200429 - Frühling.mp3", "title": "Frühling", "duration": 693.5510204081633, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200427 - Podcast.mp3", "title": "Podcast", "duration": 1087.37306122449, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200424 - Musik.mp3", "title": "Musik", "duration": 1691.8204081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200422 - Europa.mp3", "title": "Europa", "duration": 837.9036734693877, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200420 - Schlechte Laune.mp3", "title": "Schlechte Laune", "duration": 531.0693877551021, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200417 - Klima.mp3", "title": "Klima", "duration": 1200.2220408163266, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200415 - Einsamkeit.mp3", "title": "Einsamkeit", "duration": 893.0220408163266, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200409 - Entwicklungsländer.mp3", "title": "Entwicklungsländer", "duration": 836.1534693877551, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200408 - Digitale Welt.mp3", "title": "Digitale Welt", "duration": 660.7151020408163, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200407 - Helden.mp3", "title": "Helden", "duration": 851.3567346938776, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200406 - Sport.mp3", "title": "Sport", "duration": 1205.942857142857, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200403 - Impfen.mp3", "title": "Impfen", "duration": 776.3591836734694, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200402 - Arbeit und Wirtschaft.mp3", "title": "Arbeit und Wirtschaft", "duration": 878.7330612244898, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200401 - Schule auf dem Sofa.mp3", "title": "Schule auf dem Sofa", "duration": 1072.3787755102042, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200331 - Supermärkte.mp3", "title": "Supermärkte", "duration": 848.7444897959184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200330 - Streit.mp3", "title": "Streit", "duration": 1166.1583673469388, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200327 - Patienten und Helfer.mp3", "title": "Patienten und Helfer", "duration": 1224.6204081632652, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200326 - Fake News.mp3", "title": "Fake News", "duration": 847.8040625, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200325 - Die Pandemie.mp3", "title": "Die Pandemie", "duration": 983.1966875, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/GEOlino Spezial/20200324 - Angst.mp3", "title": "Angst", "duration": 820.32325, "analysis": null}], "files": [["20260805 - Zu Besuch auf einem Gandenhof.mp3", 20909009, 1787745794], ["20260701 - Jaguare und Geparden.mp3", 20637945, 1787745797], ["20260603 - Katzen.mp3", 25182247, 1787745805], ["20260506 - Azteken.mp3", 29599025, 1787745820], ["20260401 - Inka.mp3", 27402914, 1787745854], ["20260304 - Maya.mp3", 27191163, 1787745882], ["20260204 - Pubertät.mp3", 16215208, 1787745837], ["20260107 - Unsere Sinne.mp3", 19189825, 1787745880], ["20251203 - Aktive Körper.mp3", 19283713, 1787745885], ["20251105 - Intelligente Roboter.mp3", 17454711, 1787745891], ["20251022 - Tierische Erfindungen.mp3", 18962303, 1787745931], ["20251008 - Leonardo da Vinci.mp3", 23067956, 1787745925], ["20250924 - Gigantisch groß.mp3", 24984709, 1787745939], ["20250910 - Dinosaurier.mp3", 21246028, 1787745933], ["20250827 - Die Götter der Griechen.mp3", 17963971, 1787745968], ["20250813 - Alexander der Große.mp3", 16899232, 1787745961], ["20250730 - Wissenschaft im antiken Griechenland.mp3", 15433903, 1787745974], ["20250716 - Sparta!.mp3", 18076579, 1787745989], ["20250702 - Alarmtiere.mp3", 18481955, 1787745988], ["20250618 - Marianengraben.mp3", 18818734, 1787746009], ["20250528 - Krachende Kontinente.mp3", 18222419, 1787746021], ["20250514 - Unsere Erde.mp3", 14464319, 1787746019], ["20250430 - Vorurteile.mp3", 30333500, 1787746054], ["20250416 - Das ist Familie.mp3", 20628255, 1787746058], ["20250402 - Buntes Deutschland.mp3", 12581836, 1787746043], ["20250319 - Teufel aus der Tiefe.mp3", 17657387, 1787746061], ["20250305 - Wölfe.mp3", 13001458, 1787746067], ["20250219 - Sonderfolge_ Die Wahl.mp3", 19209022, 1787746100], ["20250212 - Sonderfolge_ Kinder an der Macht.mp3", 19702734, 1787746113], ["20250205 - Sonderfolge_ Demokratie.mp3", 21673348, 1787746112], ["20250122 - Löwen.mp3", 11659880, 1787746105], ["20250108 - Raubtiere.mp3", 15114558, 1787746133], ["20241225 - Das alte Ägypten_ Im Reich der Toten.mp3", 14730857, 1787746135], ["20241211 - Alltag im alten Ägypten.mp3", 20551392, 1787746163], ["20241127 - Das alte Ägypten_ Hieroglyphen.mp3", 16401097, 1787746164], ["20241113 - Das alte Ägypten_ Pyramiden.mp3", 22405825, 1787746181], ["20241030 - Die US-Wahl.mp3", 32154604, 1787746211], ["20241016 - Mammutbäume.mp3", 12725228, 1787746193], ["20241002 - Die Geschichte der USA – Teil 2.mp3", 20843380, 1787746210], ["20240918 - Die Geschichte der USA – Teil 1.mp3", 20629937, 1787746224], ["20240904 - Ötzi, der Mann aus dem Eis.mp3", 14038615, 1787746213], ["20240821 - Tierische Ermittler.mp3", 19058174, 1787746247], ["20240807 - Spurensicherung.mp3", 16286921, 1787746247], ["20240724 - Cybermobbing.mp3", 17674191, 1787746246], ["20240710 - Olympia.mp3", 18136656, 1787746274], ["20240626 - Nationalflaggen.mp3", 16076798, 1787746266], ["20240612 - Fußball.mp3", 13213584, 1787746285], ["20240529 - Sport.mp3", 13496437, 1787746269], ["20240515 - Raketen im Regenwald.mp3", 18098235, 1787746318], ["20240501 - Giftige Tiere.mp3", 14246479, 1787746293], ["20240417 - Amazonas_ Vom Boden bis zur Baumkrone.mp3", 15183827, 1787746315], ["20240403 - Regenwald.mp3", 15125051, 1787746313], ["20240320 - Burgen im Mittelalter.mp3", 18978106, 1787746346], ["20240306 - Mittelalter.mp3", 21400673, 1787746369], ["20240221 - Roboter.mp3", 21302203, 1787746355], ["20240207 - Brauchen wir Regeln für KI_.mp3", 15595503, 1787746349], ["20240124 - Künstliche Intelligenz.mp3", 15153670, 1787746393], ["20240110 - Außerirdisches Leben.mp3", 17756130, 1787746390], ["20231227 - Das Universum.mp3", 20489933, 1787746414], ["20231213 - Stern von Bethlehem.mp3", 13831840, 1787746411], ["20231129 - Tutanchamun.mp3", 18627906, 1787746438], ["20231115 - Archäologie.mp3", 15293467, 1787746427], ["20231101 - Geschwister.mp3", 17019844, 1787746455], ["20231018 - Eine elefantastische Familie.mp3", 15208381, 1787746447], ["20231004 - Gene und Vererbung.mp3", 21736994, 1787746483], ["20230920 - Du bist Familie.mp3", 18517027, 1787746476], ["20230906 - Welt ohne Insekten.mp3", 12821816, 1787746472], ["20230823 - Heuschrecken.mp3", 15414610, 1787746482], ["20230809 - Das Superinsekt_ Ameisen.mp3", 13288789, 1787746491], ["20230726 - Wut und Hoffnung_ _Fridays for Future_.mp3", 13523180, 1787746528], ["20230712 - Klimawandel in der ganzen Welt.mp3", 12481683, 1787746510], ["20230628 - Klimawandel in Deutschland.mp3", 14327635, 1787746519], ["20230614 - Edelweißpiraten_ Gegen Hitler kämpfen.mp3", 15205689, 1787746527], ["20230531 - Die Nazis_ Hitler war ein Mensch.mp3", 16581840, 1787746552], ["20230517 - Feinde der Nazis_ Die wilden Cliquen in Berlin.mp3", 12252646, 1787746551], ["20230503 - Geschichte - das bist Du!.mp3", 15629437, 1787746551], ["20230419 - Wal gegen Mensch_ Wer ist das Monster_.mp3", 13852810, 1787746555], ["20230412 - Wale - die größten Sänger der Welt.mp3", 14051327, 1787746583], ["20230405 - Vaquitas - die kleinsten Wale der Welt.mp3", 16094419, 1787746588], ["20230329 - _I have a dream_.mp3", 19973502, 1787746579], ["20230322 - Winterschlaf im Weltraum.mp3", 14638588, 1787746584], ["20230315 - Das ist ja zum Einschlafen!.mp3", 14399901, 1787746613], ["20230308 - Sag mal, träumst Du_.mp3", 21970778, 1787746638], ["20230301 - Inuk heißt Mensch.mp3", 16742226, 1787746622], ["20230222 - Shackleton.mp3", 19297771, 1787746639], ["20230215 - Wer hat Angst vorm Walross_.mp3", 16014170, 1787746656], ["20230208 - Kälte ist cool.mp3", 15576185, 1787746671], ["20221221 - Weihnachten_ Jetzt wird gebacken!.mp3", 25368885, 1787746673], ["20221214 - Gab es Jesus wirklich_.mp3", 24089471, 1787746694], ["20221207 - Wie Weihnachten klingt.mp3", 17239942, 1787746677], ["20221130 - Schokolade, Gefühle und Weihnachten.mp3", 16361980, 1787746701], ["20221123 - Kinderrechte_ Reise durch die Zeit.mp3", 18482740, 1787746714], ["20221116 - Kinderrechte_ Das könnt ihr tun!.mp3", 14679051, 1787746722], ["20221109 - Kinderrechte_ Euer Tag mit Rapper DIKKA.mp3", 10854343, 1787746717], ["20221102 - Kinderrechte.mp3", 15613517, 1787746731], ["20221026 - Schneeleoparden.mp3", 13327692, 1787746745], ["20221019 - Gletscher.mp3", 10082504, 1787746742], ["20221012 - Vulkane.mp3", 14447019, 1787746766], ["20221005 - Berge.mp3", 10966171, 1787746764], ["20220928 - Echte Verbrechen_ Fälschungen.mp3", 13753118, 1787746758], ["20220921 - Echte Verbrechen_ Hochstapler.mp3", 17172504, 1787746785], ["20220914 - Echte Verbrechen_ Raub im Regenwald.mp3", 15669348, 1787746802], ["20220907 - Echte Verbrechen_ Die verschwundene Violine.mp3", 15253457, 1787746797], ["20220831 - Laub.mp3", 11736265, 1787746791], ["20220824 - Hirsche.mp3", 18108050, 1787746835], ["20220817 - Das Wald-Internet.mp3", 13699008, 1787746826], ["20220810 - Wie wir den Wald retten.mp3", 20614140, 1787746842], ["20220803 - Unser Wald.mp3", 16740672, 1787746831], ["20220615 - Sprache.mp3", 14591885, 1787746846], ["20220608 - Bakterien.mp3", 14171648, 1787746868], ["20220601 - Fantasie.mp3", 14838320, 1787746875], ["20220525 - Luft.mp3", 14515938, 1787746873], ["20220517 - Unsichtbare Welten.mp3", 16054079, 1787746880], ["20220511 - Tierisch schlau_ Raben.mp3", 12773514, 1787746899], ["20220504 - Tierisch schlau_ Tierfamilien.mp3", 13901551, 1787746913], ["20220427 - Tierisch schlau_ Kraken.mp3", 13076755, 1787746902], ["20220420 - Tierisch schlau_ Intelligenz der Tiere.mp3", 15957863, 1787746905], ["20220413 - Raub im Kinosaal.mp3", 15796713, 1787746933], ["20220406 - Walt Disney.mp3", 23049965, 1787746935], ["20220330 - Fernsehen.mp3", 17422925, 1787746941], ["20220323 - Die Welt des Films.mp3", 16539652, 1787746948], ["20220316 - Krieg in der Ukraine_ Wie können wir helfen_.mp3", 24786811, 1787746999], ["20220309 - Krieg in der Ukraine_ Was stimmt, was stimmt nicht_.mp3", 35067117, 1787747014], ["20220302 - Krieg in der Ukraine_ Was ist passiert_.mp3", 22552488, 1787746980], ["20220223 - Helden im Tierreich.mp3", 17494806, 1787746992], ["20220216 - Superhelden.mp3", 18437506, 1787747022], ["20220209 - Zivilcourage.mp3", 9931173, 1787747015], ["20220202 - Von Heldinnen und Helden.mp3", 18449496, 1787747029], ["20211222 - Gold.mp3", 17666987, 1787747055], ["20211215 - Die sieben Naturwunder.mp3", 13724897, 1787747051], ["20211208 - Die Geschichte Afrikas.mp3", 20529052, 1787747066], ["20211201 - Afrika.mp3", 17014218, 1787747083], ["20211124 - Woraus unsere Welt besteht.mp3", 13792155, 1787747091], ["20211117 - Physik im Tierreich.mp3", 14289270, 1787747086], ["20211110 - Die Schwerkraft.mp3", 19220871, 1787747108], ["20211103 - Rätsel des Alltags.mp3", 11655996, 1787747099], ["20211027 - Die Sagen der Wikinger.mp3", 13799223, 1787747128], ["20211020 - Der Handel der Wikinger.mp3", 21886009, 1787747138], ["20211013 - Die Reisen der Wikinger.mp3", 18174740, 1787747134], ["20211006 - Die Raubzüge der Wikinger.mp3", 12171823, 1787747140], ["20210929 - Demokratie im Tierreich.mp3", 13264314, 1787747154], ["20210922 - Demokratie_ Die Wahl.mp3", 18497436, 1787747160], ["20210915 - Demokratie_ Kinder an der Macht.mp3", 21326362, 1787747187], ["20210908 - Demokratie.mp3", 18223668, 1787747176], ["20210901 - Wie wir die Welt retten_ Tiertafel.mp3", 13914967, 1787747171], ["20210825 - Wie wir die Welt retten_ Haare.mp3", 16871871, 1787747204], ["20210818 - Wie wir die Welt retten_ Lob.mp3", 11924580, 1787747199], ["20210811 - Wie wir die Welt retten_ Bienen.mp3", 18275796, 1787747209], ["20210804 - Artenschutz vor unserer Haustür.mp3", 25364480, 1787747242], ["20210728 - Bedrohte Arten_ Lichtverschmutzung.mp3", 19925112, 1787747229], ["20210721 - Bedrohte Arten_ Haie.mp3", 12530216, 1787747217], ["20210714 - Bedrohte Arten.mp3", 22555071, 1787747263], ["20210707 - Sonne.mp3", 11040654, 1787747239], ["20210630 - Sand.mp3", 12988189, 1787747267], ["20210519 - Musik_ Eure Fragen an Deine Freunde.mp3", 18631037, 1787747298], ["20210512 - Musik_ Applaus.mp3", 17528053, 1787747302], ["20210505 - Musik im Tierreich.mp3", 15668173, 1787747292], ["20210428 - Musik.mp3", 18155852, 1787747315], ["20210421 - Unser Körper_ Haut.mp3", 10810386, 1787747308], ["20210415 - Unsere Körpergeräusche.mp3", 12261216, 1787747338], ["20210407 - Unser Körper_ Knochen.mp3", 15697632, 1787747345], ["20210331 - Unser Körper.mp3", 13511670, 1787747342], ["20210324 - Erfindungen_ Louis Braille.mp3", 18655176, 1787747355], ["20210316 - Erfinden – so geht´s!.mp3", 17415718, 1787747368], ["20210310 - Erfindungen_ Ideen-Klau.mp3", 13695090, 1787747382], ["20210303 - Erfindungen.mp3", 11082548, 1787747364], ["20210224 - Eiszeit_ Mammuts.mp3", 14963871, 1787747395], ["20210217 - Eiszeit_ Forschung.mp3", 15351351, 1787747403], ["20210210 - Eiszeit_ Leben in der Eiszeit.mp3", 17152175, 1787747405], ["20210203 - Eiszeit.mp3", 12233778, 1787747416], ["20201216 - Weihnachten.mp3", 19291268, 1787747447], ["20201209 - Wintersport.mp3", 16555237, 1787747424], ["20201202 - Feuer.mp3", 11238054, 1787747430], ["20201125 - Frieren.mp3", 11673382, 1787747450], ["20201118 - Säugetiere_ Milch.mp3", 12491972, 1787747450], ["20201111 - Säugetiere_ Verrückte Arten.mp3", 14058007, 1787747453], ["20201104 - Säugetiere_ Elefanten.mp3", 11843322, 1787747476], ["20201028 - Säugetiere.mp3", 17909770, 1787747488], ["20201021 - Das alte Rom_ Gladiatoren.mp3", 16168144, 1787747488], ["20201014 - Das alte Rom_ Leben im alten Rom.mp3", 21205757, 1787747493], ["20201007 - Das alte Rom_ Caesar.mp3", 15324278, 1787747518], ["20200930 - Das alte Rom.mp3", 16635410, 1787747523], ["20200923 - Weltall_ Satelliten.mp3", 12412084, 1787747519], ["20200916 - Weltall_ Mars.mp3", 14626992, 1787747510], ["20200909 - Weltall_ Raumfahrt.mp3", 17873108, 1787747583], ["20200902 - Weltall.mp3", 14229783, 1787747581], ["20200826 - Ozeane_ Plastikmüll.mp3", 14859172, 1787747580], ["20200819 - Ozeane_ Forschung.mp3", 13713861, 1787747579], ["20200812 - Ozeane_ Wale.mp3", 10704471, 1787747648], ["20200805 - Ozeane.mp3", 14747410, 1787747660], ["20200624 - Helfen.mp3", 14178959, 1787747671], ["20200617 - Expedition ins Eis.mp3", 13562242, 1787747665], ["20200610 - Lachen.mp3", 18577911, 1787747724], ["20200603 - Fußball.mp3", 11483477, 1787747700], ["20200527 - Smartphone.mp3", 18274658, 1787747740], ["20200520 - Wasser.mp3", 10591581, 1787747704], ["20200513 - Zeit.mp3", 13600975, 1787747760], ["20200506 - Haustiere.mp3", 13918710, 1787747757], ["20200429 - Frühling.mp3", 11680042, 1787747753], ["20200427 - Podcast.mp3", 17981288, 1787747797], ["20200424 - Musik.mp3", 27652336, 1787747848], ["20200422 - Europa.mp3", 13989904, 1787747819], ["20200420 - Schlechte Laune.mp3", 9080071, 1787747789], ["20200417 - Klima.mp3", 19786820, 1787747860], ["20200415 - Einsamkeit.mp3", 14871502, 1787747845], ["20200409 - Entwicklungsländer.mp3", 13961678, 1787747840], ["20200408 - Digitale Welt.mp3", 11154647, 1787747877], ["20200407 - Helden.mp3", 14205078, 1787747908], ["20200406 - Sport.mp3", 19878426, 1787747911], ["20200403 - Impfen.mp3", 13004988, 1787747908], ["20200402 - Arbeit und Wirtschaft.mp3", 14642980, 1787747908], ["20200401 - Schule auf dem Sofa.mp3", 17741197, 1787747959], ["20200331 - Supermärkte.mp3", 14163107, 1787747968], ["20200330 - Streit.mp3", 19241687, 1787747977], ["20200327 - Patienten und Helfer.mp3", 20177088, 1787747987], ["20200326 - Fake News.mp3", 13615548, 1787747998], ["20200325 - Die Pandemie.mp3", 15781833, 1787748004], ["20200324 - Angst.mp3", 13175851, 1787748003]]}, {"id": "9bf448484625", "section": "Kinderpodcasts", "kind": "book", "title": "Kakadu", "artist": "Kakadu", "series": "Kakadu", "figure": null, "colors": ["#7b2249", "#63900a", "#d15e91"], "folder": "/home/martin/Music/Kinderpodcasts/Kakadu", "cover": "/home/martin/code/musicmouse/python-backend/.musicmouse-cache/covers/9bf448484625.jpg", "tracks": [{"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260825 - Kakadu - bei euch - Lily lebt auf einem Selbstversorgerhof.mp3", "title": "Kakadu - bei euch - Lily lebt auf einem Selbstversorgerhof", "duration": 779.964081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260821 - Kakadu - eure Fragen_ - Sind alle Quallen gefährlich_.mp3", "title": "Kakadu - eure Fragen: - Sind alle Quallen gefährlich?", "duration": 1527.1444897959184, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260818 - Kakadu - bei euch - Frieda rudert.mp3", "title": "Kakadu - bei euch - Frieda rudert", "duration": 778.684081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260814 - Kakadu - eure Fragen_ - Warum stinken wir manchmal_.mp3", "title": "Kakadu - eure Fragen: - Warum stinken wir manchmal?", "duration": 1517.0089795918368, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260811 - Kakadu - bei euch - Seraphin ist in der Lego-AG.mp3", "title": "Kakadu - bei euch - Seraphin ist in der Lego-AG", "duration": 773.9297959183673, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260807 - Kakadu - eure Fragen_ - Ist Rosa eine Farbe für Mädchen und Blau eine Farbe für Jungs_.mp3", "title": "Kakadu - eure Fragen: - Ist Rosa eine Farbe für Mädchen und Blau eine Farbe für Jungs?", "duration": 1464.3722448979593, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260731 - Kakadu - eure Fragen_ - Wie viel Affe steckt in mir_.mp3", "title": "Kakadu - eure Fragen: - Wie viel Affe steckt in mir?", "duration": 1273.8351020408163, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260728 - Kakadu - bei euch - Lotte macht Schleim selbst.mp3", "title": "Kakadu - bei euch - Lotte macht Schleim selbst", "duration": 777.2995918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260728 - Kakadu - bei euch - Bruno sammelt Müll.mp3", "title": "Kakadu - bei euch - Bruno sammelt Müll", "duration": 659.6440816326531, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260724 - Kakadu - eure Fragen_ - Ist faul sein auch gut oder nur schlecht_.mp3", "title": "Kakadu - eure Fragen: - Ist faul sein auch gut oder nur schlecht?", "duration": 1563.9771428571428, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260721 - Kakadu - bei euch - Samson hat eine Leihoma.mp3", "title": "Kakadu - bei euch - Samson hat eine Leihoma", "duration": 795.2457142857143, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260717 - Kakadu - eure Fragen_ - Können Tiere lügen_.mp3", "title": "Kakadu - eure Fragen: - Können Tiere lügen?", "duration": 1657.6261224489797, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260714 - Kakadu - bei euch - Fritz schreibt für die Schulzeitung.mp3", "title": "Kakadu - bei euch - Fritz schreibt für die Schulzeitung", "duration": 780.3297959183674, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260710 - Kakadu - eure Fragen_ - Wo wohnen Wildbienen_.mp3", "title": "Kakadu - eure Fragen: - Wo wohnen Wildbienen?", "duration": 1485.244081632653, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260707 - Kakadu - bei euch - Felix spielt Korfball.mp3", "title": "Kakadu - bei euch - Felix spielt Korfball", "duration": 738.1942857142857, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260703 - Kakadu eure Fragen_ - Wer bestimmt die Schulfächer_.mp3", "title": "Kakadu eure Fragen: - Wer bestimmt die Schulfächer?", "duration": 1555.6702040816326, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260630 - Kakadu - bei euch - Niklas ist Schiedsrichter.mp3", "title": "Kakadu - bei euch - Niklas ist Schiedsrichter", "duration": 803.5265306122449, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260629 - Kakadu - eure Fragen_ - Seit wann gibt es Mathematik und wer hat sie erfunden_.mp3", "title": "Kakadu - eure Fragen: - Seit wann gibt es Mathematik und wer hat sie erfunden?", "duration": 1513.848163265306, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260623 - Kakadu - bei euch - Kubi liebt Pokémon.mp3", "title": "Kakadu - bei euch - Kubi liebt Pokémon", "duration": 767.5036734693878, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260619 - Kakadu - eure Fragen_ - Was ist eigentlich KI_.mp3", "title": "Kakadu - eure Fragen: - Was ist eigentlich KI?", "duration": 1571.3175510204082, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260616 - Kakadu - bei euch - Isabella hat Allergien.mp3", "title": "Kakadu - bei euch - Isabella hat Allergien", "duration": 757.1069387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260612 - Kakadu - eure Fragen_ - Warum zählen Menschen Vögel_.mp3", "title": "Kakadu - eure Fragen: - Warum zählen Menschen Vögel?", "duration": 1577.0383673469387, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260611 - Kakadu - eure Fragen_ - Ist Kochen eigentlich schwer_.mp3", "title": "Kakadu - eure Fragen: - Ist Kochen eigentlich schwer?", "duration": 1405.831836734694, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260609 - Kakadu - bei euch - Greta ist Klassensprecherin.mp3", "title": "Kakadu - bei euch - Greta ist Klassensprecherin", "duration": 778.370612244898, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260602 - Kakadu - bei euch - Carl spielt Akkordeon.mp3", "title": "Kakadu - bei euch - Carl spielt Akkordeon", "duration": 773.2767346938775, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260529 - Kakadu - eure Fragen_ - Wer hat eigentlich das Klo erfunden_.mp3", "title": "Kakadu - eure Fragen: - Wer hat eigentlich das Klo erfunden?", "duration": 1539.3175510204082, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260526 - Kakadu - bei euch - Piet spricht Plattdeutsch.mp3", "title": "Kakadu - bei euch - Piet spricht Plattdeutsch", "duration": 776.6987755102041, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260522 - Kakadu - eure Fragen_ - Helfen Kuscheltiere beim Einschlafen_.mp3", "title": "Kakadu - eure Fragen: - Helfen Kuscheltiere beim Einschlafen?", "duration": 1426.7036734693877, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260519 - Kakadu - bei euch - Emily liebt K-Pop.mp3", "title": "Kakadu - bei euch - Emily liebt K-Pop", "duration": 719.8302040816327, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260515 - Kakadu - eure Frage_ - Sind Faultiere wirklich faul_.mp3", "title": "Kakadu - eure Frage: - Sind Faultiere wirklich faul?", "duration": 1529.3910204081633, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260512 - Kakadu - bei euch - Celina repariert Fahrräder.mp3", "title": "Kakadu - bei euch - Celina repariert Fahrräder", "duration": 640.5485714285714, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260508 - Kakadu - eure Frage_ - Wie wird Käse gemacht_.mp3", "title": "Kakadu - eure Frage: - Wie wird Käse gemacht?", "duration": 1511.3926530612246, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260505 - Kakadu feiert - Kakadus große Geburtstagssause.mp3", "title": "Kakadu feiert - Kakadus große Geburtstagssause", "duration": 3230.5110204081634, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260505 - Kakadu - bei euch - Anton ist Polizei-Fan.mp3", "title": "Kakadu - bei euch - Anton ist Polizei-Fan", "duration": 747.5983673469387, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260501 - Welturaufführung im Kakadu - Die Kakadu Hymne.mp3", "title": "Welturaufführung im Kakadu - Die Kakadu Hymne", "duration": 78.15836734693877, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260501 - Kakadu - eure Fragen_ - Warum haben Schnecken so viel Schleim_.mp3", "title": "Kakadu - eure Fragen: - Warum haben Schnecken so viel Schleim?", "duration": 1519.5428571428572, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260428 - Kakadu - bei euch - Nussrat spielt im inklusiven Jugendhaus.mp3", "title": "Kakadu - bei euch - Nussrat spielt im inklusiven Jugendhaus", "duration": 646.8702040816327, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260424 - Kakadu - eure Fragen_ - Warum wird uns manchmal übel_.mp3", "title": "Kakadu - eure Fragen: - Warum wird uns manchmal übel?", "duration": 1393.0579591836736, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260421 - Kakadu - bei euch - Sergej liebt den Bauspielplatz.mp3", "title": "Kakadu - bei euch - Sergej liebt den Bauspielplatz", "duration": 723.6440816326531, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260417 - Kakadu - eure Fragen_ - Warum nicken wir mit dem Kopf, wenn wir ja sagen_.mp3", "title": "Kakadu - eure Fragen: - Warum nicken wir mit dem Kopf, wenn wir ja sagen?", "duration": 1530.6971428571428, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260414 - Kakadu - bei euch - Matti liebt den Wald.mp3", "title": "Kakadu - bei euch - Matti liebt den Wald", "duration": 763.2195918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260410 - Kakadu - eure Fragen_ - Warum gibt es süßes und salziges Wasser_.mp3", "title": "Kakadu - eure Fragen: - Warum gibt es süßes und salziges Wasser?", "duration": 1382.8702040816327, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260407 - Kakadu - bei euch - Julia fährt Seifenkiste.mp3", "title": "Kakadu - bei euch - Julia fährt Seifenkiste", "duration": 756.2187755102041, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260403 - Kakadu - eure Fragen_ - Wozu sind unsere Zehen gut_.mp3", "title": "Kakadu - eure Fragen: - Wozu sind unsere Zehen gut?", "duration": 1513.7436734693877, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260331 - Kakadu - bei euch - Runa ist Konfliktlotsin.mp3", "title": "Kakadu - bei euch - Runa ist Konfliktlotsin", "duration": 767.869387755102, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260327 - Kakadu - eure Fragen_ - Was hilft gegen die Angst vor Spinnen_.mp3", "title": "Kakadu - eure Fragen: - Was hilft gegen die Angst vor Spinnen?", "duration": 1551.4906122448979, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260324 - Kakadu - bei euch - Lennard lernt tauchen.mp3", "title": "Kakadu - bei euch - Lennard lernt tauchen", "duration": 818.2595918367347, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260320 - Kakadu - eure Fragen_ - Warum heißt ein Stein Stein, oder_ Wer hat den Dingen einen Namen gegeben_.mp3", "title": "Kakadu - eure Fragen: - Warum heißt ein Stein Stein, oder: Wer hat den Dingen einen Namen gegeben?", "duration": 1557.890612244898, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260317 - Kakadu - bei euch - Mia spricht Gebärdensprache.mp3", "title": "Kakadu - bei euch - Mia spricht Gebärdensprache", "duration": 696.32, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260313 - Kakadu - eure Fragen_ - Warum gibt es Wolken_.mp3", "title": "Kakadu - eure Fragen: - Warum gibt es Wolken?", "duration": 1469.5967346938776, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260310 - Kakadu - bei euch - Emilia liebt Bibliotheken.mp3", "title": "Kakadu - bei euch - Emilia liebt Bibliotheken", "duration": 779.4938775510204, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260306 - Kakadu - eure Fragen_ - Muss die Schule so früh anfangen_.mp3", "title": "Kakadu - eure Fragen: - Muss die Schule so früh anfangen?", "duration": 1562.7493877551021, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260303 - Kakadu - bei euch - Guendalina spricht drei Sprachen.mp3", "title": "Kakadu - bei euch - Guendalina spricht drei Sprachen", "duration": 778.7624489795918, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260227 - Kakadu - eure Fragen_ - Darf mein Handy beim Essen dabei sein_.mp3", "title": "Kakadu - eure Fragen: - Darf mein Handy beim Essen dabei sein?", "duration": 1462.073469387755, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Kakadu/20260224 - Kakadu - bei euch - Milla hat endlich eine Katze bekommen.mp3", "title": "Kakadu - bei euch - Milla hat endlich eine Katze bekommen", "duration": 783.6995918367347, "analysis": null}], "files": [["20260825 - Kakadu - bei euch - Lily lebt auf einem Selbstversorgerhof.mp3", 13145571, 1787750951], ["20260821 - Kakadu - eure Fragen_ - Sind alle Quallen gefährlich_.mp3", 24634400, 1787750962], ["20260818 - Kakadu - bei euch - Frieda rudert.mp3", 12761529, 1787750952], ["20260814 - Kakadu - eure Fragen_ - Warum stinken wir manchmal_.mp3", 24569631, 1787750964], ["20260811 - Kakadu - bei euch - Seraphin ist in der Lego-AG.mp3", 12789988, 1787750963], ["20260807 - Kakadu - eure Fragen_ - Ist Rosa eine Farbe für Mädchen und Blau eine Farbe für Jungs_.mp3", 23762366, 1787750975], ["20260731 - Kakadu - eure Fragen_ - Wie viel Affe steckt in mir_.mp3", 20656893, 1787750987], ["20260728 - Kakadu - bei euch - Lotte macht Schleim selbst.mp3", 12631090, 1787750977], ["20260728 - Kakadu - bei euch - Bruno sammelt Müll.mp3", 10555071, 1787750976], ["20260724 - Kakadu - eure Fragen_ - Ist faul sein auch gut oder nur schlecht_.mp3", 25696130, 1787750999], ["20260721 - Kakadu - bei euch - Samson hat eine Leihoma.mp3", 13158831, 1787750990], ["20260717 - Kakadu - eure Fragen_ - Können Tiere lügen_.mp3", 26763164, 1787751006], ["20260714 - Kakadu - bei euch - Fritz schreibt für die Schulzeitung.mp3", 13129797, 1787750999], ["20260710 - Kakadu - eure Fragen_ - Wo wohnen Wildbienen_.mp3", 24235484, 1787751014], ["20260707 - Kakadu - bei euch - Felix spielt Korfball.mp3", 12064945, 1787751011], ["20260703 - Kakadu eure Fragen_ - Wer bestimmt die Schulfächer_.mp3", 25134872, 1787751026], ["20260630 - Kakadu - bei euch - Niklas ist Schiedsrichter.mp3", 13079832, 1787751019], ["20260629 - Kakadu - eure Fragen_ - Seit wann gibt es Mathematik und wer hat sie erfunden_.mp3", 24734317, 1787751036], ["20260623 - Kakadu - bei euch - Kubi liebt Pokémon.mp3", 12525141, 1787751028], ["20260619 - Kakadu - eure Fragen_ - Was ist eigentlich KI_.mp3", 25494873, 1787751043], ["20260616 - Kakadu - bei euch - Isabella hat Allergien.mp3", 12438795, 1787751040], ["20260612 - Kakadu - eure Fragen_ - Warum zählen Menschen Vögel_.mp3", 25578336, 1787751054], ["20260611 - Kakadu - eure Fragen_ - Ist Kochen eigentlich schwer_.mp3", 22866975, 1787751059], ["20260609 - Kakadu - bei euch - Greta ist Klassensprecherin.mp3", 12454702, 1787751052], ["20260602 - Kakadu - bei euch - Carl spielt Akkordeon.mp3", 12373197, 1787751055], ["20260529 - Kakadu - eure Fragen_ - Wer hat eigentlich das Klo erfunden_.mp3", 24741495, 1787751077], ["20260526 - Kakadu - bei euch - Piet spricht Plattdeutsch.mp3", 13128551, 1787751068], ["20260522 - Kakadu - eure Fragen_ - Helfen Kuscheltiere beim Einschlafen_.mp3", 22827997, 1787751079], ["20260519 - Kakadu - bei euch - Emily liebt K-Pop.mp3", 11518034, 1787751071], ["20260515 - Kakadu - eure Frage_ - Sind Faultiere wirklich faul_.mp3", 24962854, 1787751093], ["20260512 - Kakadu - bei euch - Celina repariert Fahrräder.mp3", 10836025, 1787751083], ["20260508 - Kakadu - eure Frage_ - Wie wird Käse gemacht_.mp3", 24542942, 1787751101], ["20260505 - Kakadu feiert - Kakadus große Geburtstagssause.mp3", 51847932, 1787751132], ["20260505 - Kakadu - bei euch - Anton ist Polizei-Fan.mp3", 12231372, 1787751099], ["20260501 - Welturaufführung im Kakadu - Die Kakadu Hymne.mp3", 1410170, 1787751095], ["20260501 - Kakadu - eure Fragen_ - Warum haben Schnecken so viel Schleim_.mp3", 24886195, 1787751120], ["20260428 - Kakadu - bei euch - Nussrat spielt im inklusiven Jugendhaus.mp3", 11125904, 1787751112], ["20260424 - Kakadu - eure Fragen_ - Warum wird uns manchmal übel_.mp3", 22538408, 1787751124], ["20260421 - Kakadu - bei euch - Sergej liebt den Bauspielplatz.mp3", 11880040, 1787751124], ["20260417 - Kakadu - eure Fragen_ - Warum nicken wir mit dem Kopf, wenn wir ja sagen_.mp3", 24691894, 1787751144], ["20260414 - Kakadu - bei euch - Matti liebt den Wald.mp3", 13165335, 1787751138], ["20260410 - Kakadu - eure Fragen_ - Warum gibt es süßes und salziges Wasser_.mp3", 22559664, 1787751148], ["20260407 - Kakadu - bei euch - Julia fährt Seifenkiste.mp3", 12579843, 1787751144], ["20260403 - Kakadu - eure Fragen_ - Wozu sind unsere Zehen gut_.mp3", 24541106, 1787751162], ["20260331 - Kakadu - bei euch - Runa ist Konfliktlotsin.mp3", 12632436, 1787751158], ["20260327 - Kakadu - eure Fragen_ - Was hilft gegen die Angst vor Spinnen_.mp3", 25083364, 1787751167], ["20260324 - Kakadu - bei euch - Lennard lernt tauchen.mp3", 13811823, 1787751162], ["20260320 - Kakadu - eure Fragen_ - Warum heißt ein Stein Stein, oder_ Wer hat den Dingen einen Namen gegeben_.mp3", 25332165, 1787751182], ["20260317 - Kakadu - bei euch - Mia spricht Gebärdensprache.mp3", 11477873, 1787751175], ["20260313 - Kakadu - eure Fragen_ - Warum gibt es Wolken_.mp3", 23638133, 1787751187], ["20260310 - Kakadu - bei euch - Emilia liebt Bibliotheken.mp3", 12706019, 1787751180], ["20260306 - Kakadu - eure Fragen_ - Muss die Schule so früh anfangen_.mp3", 25344922, 1787751196], ["20260303 - Kakadu - bei euch - Guendalina spricht drei Sprachen.mp3", 12618277, 1787751193], ["20260227 - Kakadu - eure Fragen_ - Darf mein Handy beim Essen dabei sein_.mp3", 23631054, 1787751206], ["20260224 - Kakadu - bei euch - Milla hat endlich eine Katze bekommen.mp3", 12924193, 1787751198]]}, {"id": "24641ea7e88a", "section": "Kinderpodcasts", "kind": "book", "title": "Wissen macht Ah", "artist": "Wissen macht Ah", "series": "Wissen macht Ah", "figure": null, "colors": ["#6c43f2", "#aa4dcc", "#c0e544"], "folder": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah", "cover": null, "tracks": [{"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260727 - Acht Arten Adieu absonderlich auszusprechen.mp3", "title": "Acht Arten Adieu absonderlich auszusprechen", "duration": 1476.072, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260608 - Magie in der Küche mit Clarissa und Tarkan.mp3", "title": "Magie in der Küche mit Clarissa und Tarkan", "duration": 1479.864, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260504 - Geschichtegeschichten.mp3", "title": "Geschichtegeschichten", "duration": 1473.0, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260413 - Ah!BC.mp3", "title": "Ah!BC", "duration": 1459.296, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260328 - Märchenwirrwarr.mp3", "title": "Märchenwirrwarr", "duration": 1479.936, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260228 - Wissen Impossible.mp3", "title": "Wissen Impossible", "duration": 1476.408, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260221 - Ah!BC.mp3", "title": "Ah!BC", "duration": 1459.296, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260216 - Wissen Impossible.mp3", "title": "Wissen Impossible", "duration": 1475.64, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260214 - Wissen macht modern.mp3", "title": "Wissen macht modern", "duration": 1471.512, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260209 - Wissen macht modern.mp3", "title": "Wissen macht modern", "duration": 1471.512, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260126 - Steckengeblieben.mp3", "title": "Steckengeblieben", "duration": 1455.048, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20260117 - Stadt, Land … Wissen.mp3", "title": "Stadt, Land … Wissen", "duration": 1479.984, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20251201 - Stadt, Land … Wissen.mp3", "title": "Stadt, Land … Wissen", "duration": 1479.24, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20251122 - Im Klo.mp3", "title": "Im Klo", "duration": 1470.48, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20251110 - Im Kosmetiksalon.mp3", "title": "Im Kosmetiksalon", "duration": 1458.192, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20251101 - In der Kantine.mp3", "title": "In der Kantine", "duration": 1479.6, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20251018 - Social MediAh!.mp3", "title": "Social MediAh!", "duration": 1471.8, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20251013 - Social MediAh!.mp3", "title": "Social MediAh!", "duration": 1471.8, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20251006 - Im Bad.mp3", "title": "Im Bad", "duration": 1460.328, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20250923 - KlimAh!.mp3", "title": "KlimAh!", "duration": 1459.704, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20250922 - KlimAh!.mp3", "title": "KlimAh!", "duration": 1459.68, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20250906 - Im Baumwipfel.mp3", "title": "Im Baumwipfel", "duration": 1476.648, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20250901 - Vermessen.mp3", "title": "Vermessen", "duration": 1471.392, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20250825 - Wissen macht blau.mp3", "title": "Wissen macht blau", "duration": 1495.512, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20250823 - Sauberer Spaß.mp3", "title": "Sauberer Spaß", "duration": 1480.296, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20250816 - Ein Pfund Gehacktes.mp3", "title": "Ein Pfund Gehacktes", "duration": 1491.6, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20241123 - Essen auf Rädern.mp3", "title": "Essen auf Rädern", "duration": 1472.256, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20241102 - Wissen macht Lärm.mp3", "title": "Wissen macht Lärm", "duration": 1468.152, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20240406 - Auf der grünen Wiese.mp3", "title": "Auf der grünen Wiese", "duration": 1465.032, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20231127 - Alles rund.mp3", "title": "Alles rund", "duration": 1480.488, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20230603 - Gesundheit!.mp3", "title": "Gesundheit!", "duration": 1480.32, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20220115 - Wissen im Schlaf.mp3", "title": "Wissen im Schlaf", "duration": 1478.28, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20210904 - Der Fluch des Ahaho.mp3", "title": "Der Fluch des Ahaho", "duration": 1494.696, "analysis": null}, {"path": "/home/martin/Music/Kinderpodcasts/Wissen macht Ah/20210522 - Dusche Nie!.mp3", "title": "Dusche Nie!", "duration": 1479.792, "analysis": null}], "files": [["20260727 - Acht Arten Adieu absonderlich auszusprechen.mp3", 23617998, 1787750293], ["20260608 - Magie in der Küche mit Clarissa und Tarkan.mp3", 23678639, 1787750296], ["20260504 - Geschichtegeschichten.mp3", 23568832, 1787750393], ["20260413 - Ah!BC.mp3", 23349669, 1787750391], ["20260328 - Märchenwirrwarr.mp3", 23679960, 1787750484], ["20260228 - Wissen Impossible.mp3", 23623242, 1787750479], ["20260221 - Ah!BC.mp3", 23349669, 1787750516], ["20260216 - Wissen Impossible.mp3", 23611172, 1787750566], ["20260214 - Wissen macht modern.mp3", 23544860, 1787750579], ["20260209 - Wissen macht modern.mp3", 23545118, 1787750581], ["20260126 - Steckengeblieben.mp3", 23281737, 1787750593], ["20260117 - Stadt, Land … Wissen.mp3", 23680415, 1787750650], ["20251201 - Stadt, Land … Wissen.mp3", 23668733, 1787750663], ["20251122 - Im Klo.mp3", 23528369, 1787750667], ["20251110 - Im Kosmetiksalon.mp3", 23331952, 1787750675], ["20251101 - In der Kantine.mp3", 23674411, 1787750749], ["20251018 - Social MediAh!.mp3", 23549740, 1787750755], ["20251013 - Social MediAh!.mp3", 23549740, 1787750758], ["20251006 - Im Bad.mp3", 23366176, 1787750739], ["20250923 - KlimAh!.mp3", 23355989, 1787750824], ["20250922 - KlimAh!.mp3", 23355605, 1787750824], ["20250906 - Im Baumwipfel.mp3", 23627205, 1787750843], ["20250901 - Vermessen.mp3", 23542908, 1787750913], ["20250825 - Wissen macht blau.mp3", 23928979, 1787750926], ["20250823 - Sauberer Spaß.mp3", 23685734, 1787750920], ["20250816 - Ein Pfund Gehacktes.mp3", 23866509, 1787750931], ["20241123 - Essen auf Rädern.mp3", 23556892, 1787750446], ["20241102 - Wissen macht Lärm.mp3", 23491325, 1787750483], ["20240406 - Auf der grünen Wiese.mp3", 23441169, 1787750386], ["20231127 - Alles rund.mp3", 23688651, 1787750300], ["20230603 - Gesundheit!.mp3", 23686138, 1787750838], ["20220115 - Wissen im Schlaf.mp3", 23653187, 1787750286], ["20210904 - Der Fluch des Ahaho.mp3", 23915879, 1787750876], ["20210522 - Dusche Nie!.mp3", 23677446, 1787750378]]}]} \ No newline at end of file diff --git a/python-backend/README.md b/python-backend/README.md index fa2e0a3..806f277 100644 --- a/python-backend/README.md +++ b/python-backend/README.md @@ -1,21 +1,22 @@ # MusicMouse backend The host application: it reads RFID tags, buttons and touch areas from the ESP32 -firmware over serial, plays music through VLC, drives three LED strips, and exposes -everything to Home Assistant over MQTT. +firmware over serial, plays music through VLC, drives three LED strips, indexes the +music collection, and exposes everything to Home Assistant over MQTT and to a browser +over HTTP. ``` - ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in) + ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in) VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤ - broker ⇄ MqttService ──────┘ ▲ └─► (a web service would go here) - │ + broker ⇄ MqttService ──────┤ ▲ └─► WebService (state out, intents in) +browser ⇄ WebService ───────┘ │ reactions/*.py ── call actions on ──► device / player ``` Three objects own the outside world, one bus carries everything, and the *reactions* are the only place that decides what should happen. Adding a new way to control the mouse means adding a service that emits the same intents - no device or reaction -changes. +changes. The web front-end was added exactly that way. ## Running it @@ -28,6 +29,32 @@ See `config.yml.example` for the schema and `musicmouse.service` for the systemd Config problems are reported all at once with the path to each one; unknown keys are errors, not silent no-ops. +### On a host with no mouse attached + +The web front-end is a complete way to drive the player, so the backend is useful on a +machine with no serial port and no sound card worth grabbing. Two config keys say so, +each by taking the literal value `simulate`: + +| Setting | `simulate` gives you | Warned about as | +|---|---|---| +| `serial_port` | no serial link; RFID, buttons and LEDs are inert | "running without the mouse" | +| `alsa_device` | the simulator's player: everything works, nothing is audible | "nothing will be audible" | + +Both keys are **required**. Leaving one out is a config error, not a shortcut to +simulation - running blind or silent has to be asked for, so a config that lost a line +fails loudly instead of booting into something that looks like it is working. The +warnings are repeated on every boot for the same reason. + +`--no-hardware` forces the serial half regardless of the config, for a one-off run: + +```sh +python -m musicmouse --config ./config.yml --no-hardware +``` + +Note that `alsa_device` has no "just use whatever" value. VLC's own default would seize +whatever the desktop is playing through, which is the wrong thing to do silently - name +a device (`"default"` is the system one) when the machine is meant to make noise. + ### Without hardware The simulator runs the entire app - real bus, real device, real reactions, real MQTT @@ -65,7 +92,9 @@ the prompt becomes a regression test by pasting it into a `.txt` file. | `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). | | `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). | | `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. | +| `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. | | `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. | +| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, and parent-mode settings. | | `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. | | `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. | @@ -118,3 +147,89 @@ trigger, action `light.toggle` on `light.kinderzimmer_fluter`. Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with that data. + +## The library + +`general.library.root` is the one path to the music. The shelves under it are fixed +names rather than settings (`musicmouse/library/sections.py`), because each has quirks +the scanner has to know about: + +| Folder | Shown as | Why it is special | +|---|---|---| +| `Figuren/
/` | an album per figurine | the folder name *is* the figure name from the config | +| `Musik/ - /` | music, grouped by artist | plain ID3 | +| `Hörbücher/ - /` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully | +| `Kinderpodcasts//` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show | + +Only files whose suffix is in `audio_extensions` are read, and dotfiles are skipped, so +a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach +a playlist. + +Each album carries three colours, pulled out of its cover art with Pillow (or +synthesised from a hash of its id when it has none). The frontend paints cards with +them and the LED strips run the first of them, so shelf and screen agree. + +### The cache + +`general.library.cache` is a directory, not a file, because its contents cost wildly +different amounts to produce: + +``` +index.json cheap: tags and structure. Rebuilt freely. +covers/.jpg medium: art extracted from an ID3 APIC frame +analysis/.json expensive: reserved for offline audio analysis +``` + +Deleting the whole directory is safe; deleting it throws away analysis that is minutes +of DSP per track, which is why anything expensive is keyed by *file content* rather than +by album id - renaming a folder or re-sorting a section then costs nothing. + +An entry is reused whenever its files' sizes and mtimes are unchanged. That means a +change to how the scanner derives a title, artist or series is invisible until the cache +is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch +that logic. + +**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape +of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with +the index, and a beat grid lives in its own file and is fetched per track - so an +analyzer can be added later without touching the scanner, the API or the frontend. It +will go behind an optional dependency group, and because results are content-keyed +files, they can equally well be computed on a workstation and the `analysis/` folder +copied to the device. + +## The web front-end + +`web/` in the repo root, served by this backend when `general.web.static_dir` is set. + +| Route | Purpose | +|---|---| +| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. | +| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. | +| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. | +| `GET /api/state` | Snapshot: what is playing, where, how loud. | +| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. | +| `POST /api/play` | `{album_id, track_index?}` | +| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | +| `POST /api/volume` | `{percent}` or `{delta_percent}` | +| `GET` `PUT /api/settings` | parent mode | + +Two decisions worth knowing: + +**Search is not an endpoint.** The whole index goes to the browser and filtering happens +there, which is what makes the design's type-to-search feel instant. + +**Volume is a percentage at this boundary.** `max_volume` is a parent's business, not a +child's, so it never crosses into the browser: `100 %` means whatever ceiling is +configured, and the mapping lives in `services/web/settings.py` so MQTT, the rotary +encoder and the firmware carry on in device units. + +### Parent mode + +`?parentMode=1` reveals a settings panel for the volume limits, the rotary step and the +button brightness. Saving writes `config.yml` back through ruamel's round-trip loader, +so the file keeps its comments, and a new ceiling applies to the running player rather +than waiting for a restart. + +This **hides** the settings; it does not protect them. There is no authentication on any +endpoint, which matches a device on a home network - put it behind a reverse proxy if +that is not good enough. diff --git a/python-backend/config.yml b/python-backend/config.yml new file mode 100644 index 0000000..aff30e5 --- /dev/null +++ b/python-backend/config.yml @@ -0,0 +1,94 @@ +# Example config for the MusicMouse backend. +# +# python -m musicmouse --config /media/musicmouse/config.yml +# +# Unknown keys are rejected rather than ignored, and every problem in the file is +# reported at once, so a typo fails at startup with the path to the offending line. +# Keep the real config (with credentials) off the repo - on the device only. + +general: + # The music collection. One path; the shelves underneath it are fixed names, not + # settings, because each one has its own quirks the code already knows about: + # + # /Figuren/
/ one folder per figurine + # /Musik/ - / albums, grouped by artist + # /Hörbücher/ - / audiobooks, grouped by character + # /Kinderpodcasts// shows, newest episode first + # + # A cover.jpg next to the audio is used if present, otherwise the art is pulled out + # of the files' tags. Relative paths resolve against this file's directory. + library: + root: /home/martin/Music + # Scan results, extracted cover art and track analysis. Safe to delete: the index + # is rebuilt on the next start. Deleting it does throw away track analysis, which + # is expensive to recompute. + cache: .musicmouse-cache + + # Serial port the ESP32 firmware is on. A dropped link is retried, not fatal. + # Required - use "simulate" to run without the mouse attached, which is a complete + # setup on its own because the web front-end can drive the player by itself. RFID, + # buttons and LEDs then do nothing, and startup says so every boot. + serial_port: "simulate" + baudrate: 115200 + reconnect_interval: 5.0 + + # ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system + # default output. Required - use "simulate" for a player that makes no sound, which + # is handy when working on the web UI on a machine whose audio you would rather not + # commandeer. Startup says so every boot. + # + # Both of these are required rather than optional on purpose: running blind or silent + # has to be asked for, so a config that lost a line fails loudly instead of booting + # into something that looks like it is working. + alsa_device: "default" + + # Volume, 0..100. min/max clamp everything, including the rotary encoder. + min_volume: 0 + max_volume: 80 + initial_volume: 40 + volume_increment: 5 # per rotary-encoder click + + # Backlight of the prev/next buttons while a figure is playing, 0..1. + button_leds_brightness: 0.5 + + # Which files count as music. Anything else - a podcast downloader's archive.json, + # a half-finished .tmp - is ignored. + audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"] + + # The web front-end. Omit the whole section to run without it. + # + # There is no authentication: this is a device on a home network. The settings panel + # at ?parentMode=1 is hidden from the child, not protected from them - it writes back + # to this file. Put it behind a reverse proxy if that is not good enough. + web: + host: "0.0.0.0" + port: 8080 + # Built frontend to serve at /. Omit to expose only the JSON API. + static_dir: ../web/dist + + # Home Assistant integration. Omit the whole section to run without MQTT. + # The backend exposes three lights, a player sensor, a volume slider, transport + # buttons, device triggers for every button/touch area, and a tag scanner. + #mqtt: + # server: "homeassistant.local" + # port: 1883 + # user: "musicmouse" + # password: "REPLACE_WITH_MQTT_PASSWORD" + # base_topic: "musicmouse" + # discovery_prefix: "homeassistant" + # device_id: "musicmouse" + # device_name: "Music Mouse" + # reconnect_interval: 10.0 + +# One entry per figurine. The key is the figure name and the subfolder name. +figures: + fuchs: + # RFID tag id, 5 bytes as hex. Must be unique across figures. + id: "04a1b2c3d4" + # Exactly four colours: primary, secondary, background, accent. + # Either "#rrggbb" (RGB) or "wNN" (white channel only, hex). + colors: ["#ff6600", "#ffcc00", "#331100", "wff"] + + eule: + id: "04b2c3d4e5" + colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"] diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index d03ed0d..5963eb0 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -7,17 +7,39 @@ # Keep the real config (with credentials) off the repo - on the device only. general: - # Root folder holding one subfolder per figure. Relative paths resolve against - # this file's directory. Each figure plays /
, - # in alphabetical order by filename. - figure_folder: music + # The music collection. One path; the shelves underneath it are fixed names, not + # settings, because each one has its own quirks the code already knows about: + # + # /Figuren/
/ one folder per figurine + # /Musik/ - / albums, grouped by artist + # /Hörbücher/ - / audiobooks, grouped by character + # /Kinderpodcasts// shows, newest episode first + # + # A cover.jpg next to the audio is used if present, otherwise the art is pulled out + # of the files' tags. Relative paths resolve against this file's directory. + library: + root: /home/martin/Music + # Scan results, extracted cover art and track analysis. Safe to delete: the index + # is rebuilt on the next start. Deleting it does throw away track analysis, which + # is expensive to recompute. + cache: .musicmouse-cache # Serial port the ESP32 firmware is on. A dropped link is retried, not fatal. + # Required - use "simulate" to run without the mouse attached, which is a complete + # setup on its own because the web front-end can drive the player by itself. RFID, + # buttons and LEDs then do nothing, and startup says so every boot. serial_port: "/dev/ttyUSB0" baudrate: 115200 reconnect_interval: 5.0 - # ALSA output device passed to VLC, e.g. "hw:0,0". Omit for VLC's default. + # ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system + # default output. Required - use "simulate" for a player that makes no sound, which + # is handy when working on the web UI on a machine whose audio you would rather not + # commandeer. Startup says so every boot. + # + # Both of these are required rather than optional on purpose: running blind or silent + # has to be asked for, so a config that lost a line fails loudly instead of booting + # into something that looks like it is working. alsa_device: "softvol_effects" # Volume, 0..100. min/max clamp everything, including the rotary encoder. @@ -29,9 +51,21 @@ general: # Backlight of the prev/next buttons while a figure is playing, 0..1. button_leds_brightness: 0.5 - # Which files count as music. Anything else in a figure folder is ignored. + # Which files count as music. Anything else - a podcast downloader's archive.json, + # a half-finished .tmp - is ignored. audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"] + # The web front-end. Omit the whole section to run without it. + # + # There is no authentication: this is a device on a home network. The settings panel + # at ?parentMode=1 is hidden from the child, not protected from them - it writes back + # to this file. Put it behind a reverse proxy if that is not good enough. + web: + host: "0.0.0.0" + port: 8080 + # Built frontend to serve at /. Omit to expose only the JSON API. + static_dir: ../web/dist + # Home Assistant integration. Omit the whole section to run without MQTT. # The backend exposes three lights, a player sensor, a volume slider, transport # buttons, device triggers for every button/touch area, and a tag scanner. @@ -54,7 +88,13 @@ figures: # Exactly four colours: primary, secondary, background, accent. # Either "#rrggbb" (RGB) or "wNN" (white channel only, hex). colors: ["#ff6600", "#ffcc00", "#331100", "wff"] + # "music" (default) or "book". Every other shelf is named after what is on it, so + # its type is obvious; a figure folder is named after the figurine, so this is the + # one thing that has to be said out loud. The web UI draws albums square and + # audiobooks taller than wide, so getting it wrong is visible at a glance. + kind: music eule: id: "04b2c3d4e5" colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"] + kind: book diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index 95d6292..905c2aa 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -1,12 +1,13 @@ """Entry point and composition root. python -m musicmouse --config /media/musicmouse/config.yml + python -m musicmouse --config ./config.yml --no-hardware python -m musicmouse --config ./config.yml --simulate python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt This is the only module that knows which concrete implementations are in play; the -difference between "real mouse" and "simulated mouse" is which transport and which -player get built here. +difference between "real mouse", "no mouse attached" and "simulated mouse" is which +transport and which player get built here. """ from __future__ import annotations @@ -24,13 +25,16 @@ from musicmouse import __version__ from musicmouse.app import App from musicmouse.bus import EventBus from musicmouse.clock import RealClock -from musicmouse.config import Config, ConfigError, build_playlists, load_config +from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.devices.null_transport import NullTransport from musicmouse.devices.player import Player, VlcPlayer from musicmouse.devices.serial_link import SerialLink +from musicmouse.library import MusicLibrary from musicmouse.reactions import register_all from musicmouse.services.base import Service from musicmouse.services.mqtt import MqttService, build_entities +from musicmouse.services.web import WebService _log = logging.getLogger("musicmouse") @@ -48,6 +52,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="run against fake hardware and a fake player (no serial port, no audio)", ) + parser.add_argument( + "--no-hardware", + action="store_true", + help="real audio and a real web front-end, but no serial port: for a host with " + "no mouse attached", + ) parser.add_argument( "--script", type=Path, @@ -64,6 +74,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: args = parser.parse_args(argv) if args.script and not args.simulate: parser.error("--script only makes sense together with --simulate") + if args.simulate and args.no_hardware: + parser.error("--simulate already runs without hardware; drop --no-hardware") return args @@ -85,7 +97,11 @@ def main(argv: list[str] | None = None) -> int: print(f"error: {exc}", file=sys.stderr) return 2 - runner = run_simulated(config, args.script) if args.simulate else run_real(config) + runner = ( + run_simulated(config, args.config, args.script) + if args.simulate + else run_real(config, args.config, hardware=wants_hardware(config, args.no_hardware)) + ) try: asyncio.run(runner) except KeyboardInterrupt: @@ -96,43 +112,53 @@ def main(argv: list[str] | None = None) -> int: # ------------------------------------------------------------------------- real -async def run_real(config: Config) -> None: +async def run_real(config: Config, config_path: Path, *, hardware: bool = True) -> None: bus = EventBus() await bus.start() clock = RealClock() general = config.general - link = SerialLink( - general.serial_port, - general.baudrate, - reconnect_interval=general.reconnect_interval, - clock=clock, - ) - mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port) - link.attach( - mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected - ) + link: SerialLink | None = None + if hardware: + link = SerialLink( + general.serial_port, + general.baudrate, + reconnect_interval=general.reconnect_interval, + clock=clock, + ) + mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port) + link.attach( + mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected + ) + else: + mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none") - player: Player = VlcPlayer( - bus, - alsa_device=general.alsa_device, - clock=clock, - **VlcPlayer.volume_kwargs(general), - ) + player = _build_player(bus, general, clock=clock) - app = _build_app(config, bus, mouse, player, clock=clock) - services = _build_services(app, mouse, player, clock=clock) + library = await build_library(config) + app = _build_app(config, bus, mouse, player, library, clock=clock) + services = _build_services( + app, mouse, player, clock=clock, config_path=config_path + ) _log.info( - "MusicMouse %s starting: %d figures, serial %s, mqtt %s", + "MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s", __version__, len(config.figures), - general.serial_port, + len(library.albums), + # Report what the config says, with a marker when --no-hardware overrode it, + # so the line never disagrees with the file it was started from. + general.serial_port + ("" if link or general.serial_simulated else " (no link)"), + general.alsa_device, general.mqtt.server if general.mqtt else "disabled", ) try: await _run_forever( - [link.run(), player.run(), *(service.run() for service in services)] + [ + *([link.run()] if link else []), + player.run(), + *(service.run() for service in services), + ] ) finally: player.close() @@ -142,7 +168,7 @@ async def run_real(config: Config) -> None: # -------------------------------------------------------------------- simulated -async def run_simulated(config: Config, script: Path | None) -> None: +async def run_simulated(config: Config, config_path: Path, script: Path | None) -> None: # Imported here so the production path never touches the simulator. from musicmouse.simulator.harness import build_simulation from musicmouse.simulator.repl import run_repl @@ -154,7 +180,9 @@ async def run_simulated(config: Config, script: Path | None) -> None: config, clock=RealClock() if script is None else None, track_duration=5.0 ) - services = _build_services(sim.app, sim.app.mouse, sim.player, clock=RealClock()) + services = _build_services( + sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path + ) tasks = [asyncio.create_task(service.run(), name=service.name) for service in services] try: if script is not None: @@ -170,11 +198,68 @@ async def run_simulated(config: Config, script: Path | None) -> None: # ---------------------------------------------------------------------- wiring +def wants_hardware(config: Config, no_hardware_flag: bool) -> bool: + """Whether to open a serial port at all. + + ``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked + for rather than inferred from a missing key, but it is still worth saying out loud + every boot - a mouse whose RFID reader does nothing should say why. + """ + if no_hardware_flag: + return False + if config.general.serial_simulated: + _log.warning( + 'general.serial_port is "%s": running without the mouse. ' + "The web front-end still works; RFID, buttons and LEDs do not.", + SIMULATE, + ) + return False + return True + + +def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player: + """A real player, or a silent one when the config asked for that. + + ``alsa_device: simulate`` gets the simulator's player: everything above it behaves + identically, it just makes no sound. Useful for working on the web UI without + commandeering the machine's audio. + """ + if not general.audio_simulated: + return VlcPlayer( + bus, + alsa_device=general.alsa_device, + clock=clock, + **VlcPlayer.volume_kwargs(general), + ) + + _log.warning( + 'general.alsa_device is "%s": using a simulated player, so nothing will be ' + 'audible. Set it to "default" for the system default output.', + SIMULATE, + ) + # Imported here rather than at module scope so the real audio path never loads the + # simulator - and so this still runs on a machine without libVLC at all. + from musicmouse.simulator.fake_player import FakePlayer + + return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general)) + + +async def build_library(config: Config) -> MusicLibrary: + library_config = config.general.library + return await MusicLibrary.build( + library_config.root, + library_config.cache, + frozenset(config.general.audio_extensions), + figure_kinds=config.figure_kinds, + ) + + def _build_app( config: Config, bus: EventBus, mouse: MusicMouseDevice, player: Player, + library: MusicLibrary, *, clock: RealClock, ) -> App: @@ -183,7 +268,11 @@ def _build_app( bus=bus, mouse=mouse, player=player, - playlists=build_playlists(config), + library=library, + # One source of truth: the figure path and the web path must hand the player the + # same Playlist object for the same folder, because `play_figure` resumes on an + # identity check. + playlists=library.figure_playlists(), clock=clock, ) register_all(bus, app) @@ -191,15 +280,30 @@ def _build_app( def _build_services( - app: App, mouse: MusicMouseDevice, player: Player, *, clock: RealClock + app: App, + mouse: MusicMouseDevice, + player: Player, + *, + clock: RealClock, + config_path: Path, ) -> list[Service]: - """Every front-end. A web service would be one more line here.""" - if app.config.general.mqtt is None: - _log.info("No mqtt section in the config: Home Assistant integration is off") - return [] + """Every front-end. Each one only speaks intents, so they cannot conflict.""" + services: list[Service] = [] + mqtt_config = app.config.general.mqtt - entities = build_entities(app.bus, mqtt_config, mouse, player) - return [MqttService(app.bus, mqtt_config, entities, clock=clock)] + if mqtt_config is None: + _log.info("No mqtt section in the config: Home Assistant integration is off") + else: + entities = build_entities(app.bus, mqtt_config, mouse, player) + services.append(MqttService(app.bus, mqtt_config, entities, clock=clock)) + + web_config = app.config.general.web + if web_config is None: + _log.info("No web section in the config: the web front-end is off") + else: + services.append(WebService(app, web_config, config_path)) + + return services async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None: diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py index 431f599..e7b2242 100644 --- a/python-backend/musicmouse/app.py +++ b/python-backend/musicmouse/app.py @@ -10,6 +10,7 @@ from musicmouse.clock import Clock, RealClock from musicmouse.config import Config, FigureColors from musicmouse.devices.mouse import MusicMouseDevice from musicmouse.devices.player import Player +from musicmouse.library import MusicLibrary from musicmouse.media import Playlist _log = logging.getLogger(__name__) @@ -25,6 +26,10 @@ class AppState: #: instead of starting over. Cleared once its playlist runs out. last_partially_played_figure: str | None = None + #: Whether the MQTT broker is currently reachable. The firmware's equivalent is + #: readable off the transport; a broker's is not, so it is remembered here. + mqtt_connected: bool = False + @dataclass class App: @@ -32,6 +37,7 @@ class App: bus: EventBus mouse: MusicMouseDevice player: Player + library: MusicLibrary playlists: dict[str, Playlist] clock: Clock = field(default_factory=RealClock) state: AppState = field(default_factory=AppState) diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index 2412698..b15def8 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging from pathlib import Path -from typing import Annotated, Any, Self +from typing import Annotated, Any, Final, Literal, Self from pydantic import ( BaseModel, @@ -26,24 +26,31 @@ from ruamel.yaml.error import YAMLError from musicmouse.color import ColorRGBW, parse_color from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH -from musicmouse.media import Playlist, build_playlist _log = logging.getLogger(__name__) __all__ = [ + "SIMULATE", "Config", "ConfigError", "FigureColors", "FigureConfig", "GeneralConfig", + "LibraryConfig", "MqttConfig", - "build_playlists", + "WebConfig", "format_validation_error", "load_config", ] DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac") +#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or +#: without sound is a supported setup, but it has to be *asked for*: a missing key is an +#: error, so a config that lost a line fails loudly instead of booting into a silent +#: mouse that looks like it is working. +SIMULATE: Final = "simulate" + class ConfigError(Exception): """Raised with an already human-readable, multi-line message.""" @@ -101,6 +108,17 @@ class FigureColors(_Strict): return data +def _resolve_folder(folder: Path, info: ValidationInfo, *, must_exist: bool) -> Path: + """Make a configured path absolute against the config file, and optionally check it.""" + context = info.context or {} + base = context.get("config_dir") + if base is not None and not folder.is_absolute(): + folder = (Path(base) / folder).resolve() + if must_exist and context.get("check_paths", True) and not folder.is_dir(): + raise ValueError(f"no such directory: {folder}") + return folder + + class MqttConfig(_Strict): server: str port: int = Field(default=1883, ge=1, le=65535) @@ -113,18 +131,63 @@ class MqttConfig(_Strict): reconnect_interval: float = Field(default=10.0, gt=0) -class GeneralConfig(_Strict): - #: Root folder holding one subfolder per figure. - figure_folder: Path +class LibraryConfig(_Strict): + """Where the music lives. - serial_port: str = "/dev/ttyUSB0" + One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``, + ``Kinderpodcasts`` - are fixed names, not settings; see + :mod:`musicmouse.library.sections`. + """ + + root: Path + #: Scan results, extracted cover art and track analysis. Relative to this file. + cache: Path = Path(".musicmouse-cache") + + @field_validator("root") + @classmethod + def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path: + return _resolve_folder(folder, info, must_exist=True) + + @field_validator("cache") + @classmethod + def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path: + return _resolve_folder(folder, info, must_exist=False) + + @property + def figure_folder(self) -> Path: + return self.root / "Figuren" + + +class WebConfig(_Strict): + """The web front-end. Omit the whole section to run without it.""" + + #: A LAN appliance with no auth; binding to all interfaces is the point. + host: str = "0.0.0.0" + port: int = Field(default=8080, ge=1, le=65535) + #: Built frontend to serve at ``/``. Omit to expose only the JSON API. + static_dir: Path | None = None + + @field_validator("static_dir") + @classmethod + def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None: + return None if folder is None else _resolve_folder(folder, info, must_exist=False) + + +class GeneralConfig(_Strict): + library: LibraryConfig + + #: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the + #: web front-end is a complete way to drive the player on its own. Required. + serial_port: str baudrate: int = Field(default=115200, gt=0) reconnect_interval: float = Field(default=5.0, gt=0) - #: ALSA output device passed to VLC, e.g. "hw:0,0"; null for VLC's default. - alsa_device: str | None = None + #: ALSA output device passed to VLC, e.g. ``"hw:0,0"`` or ``"default"``, or + #: ``"simulate"`` for a player that makes no sound. Required. + alsa_device: str mqtt: MqttConfig | None = None + web: WebConfig | None = None min_volume: int = Field(default=0, ge=0, le=200) max_volume: int = Field(default=100, ge=0, le=200) @@ -134,6 +197,14 @@ class GeneralConfig(_Strict): audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS + @property + def serial_simulated(self) -> bool: + return self.serial_port == SIMULATE + + @property + def audio_simulated(self) -> bool: + return self.alsa_device == SIMULATE + @model_validator(mode="after") def _check_volumes(self) -> Self: if self.min_volume > self.max_volume: @@ -147,22 +218,15 @@ class GeneralConfig(_Strict): ) return self - @field_validator("figure_folder") - @classmethod - def _resolve_figure_folder(cls, folder: Path, info: ValidationInfo) -> Path: - context = info.context or {} - base = context.get("config_dir") - if base is not None and not folder.is_absolute(): - folder = (Path(base) / folder).resolve() - if context.get("check_paths", True) and not folder.is_dir(): - raise ValueError(f"no such directory: {folder}") - return folder - class FigureConfig(_Strict): #: RFID tag id as hex, e.g. "04a1b2c3d4". id: TagId colors: FigureColors + #: What this figure holds. Unlike the other shelves a figure folder is named after + #: the figurine rather than its contents, so nothing on disk says whether it is an + #: album or an audiobook - and the browse view draws the two differently. + kind: Literal["music", "book"] = "music" class Config(_Strict): @@ -185,16 +249,13 @@ class Config(_Strict): """Tag id -> figure name, as handed to the device.""" return {figure.id: name for name, figure in self.figures.items()} + @property + def figure_kinds(self) -> dict[str, Literal["music", "book"]]: + """Figure name -> what it holds, as handed to the library scanner.""" + return {name: figure.kind for name, figure in self.figures.items()} + def folder_for(self, figure: str) -> Path: - return self.general.figure_folder / figure - - -def build_playlists(config: Config) -> dict[str, Playlist]: - """One playlist per figure, from ``/``, alphabetically.""" - return { - name: build_playlist(name, config.folder_for(name), config.general.audio_extensions) - for name in config.figures - } + return self.general.library.figure_folder / figure def format_validation_error(error: ValidationError) -> str: @@ -209,6 +270,8 @@ def format_validation_error(error: ValidationError) -> str: message = message.removeprefix(prefix) if entry["type"] == "extra_forbidden": message = "unknown option (check the spelling against config.yml.example)" + elif entry["type"] == "missing": + message = "required (see config.yml.example)" lines.append(f" {location or ''}: {message}") plural = "s" if len(lines) != 1 else "" return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines) diff --git a/python-backend/musicmouse/devices/null_transport.py b/python-backend/musicmouse/devices/null_transport.py new file mode 100644 index 0000000..ef5c32f --- /dev/null +++ b/python-backend/musicmouse/devices/null_transport.py @@ -0,0 +1,22 @@ +"""A transport with nothing on the other end. + +The web front-end is a complete way to drive the mouse, so the backend has to be +useful on a machine that has no mouse attached - a spare Pi, a laptop, a container. +Rather than making :class:`~musicmouse.devices.mouse.MusicMouseDevice` optional and +teaching every reaction to cope with its absence, the device is built as usual against +a transport that drops what it is handed. Lighting reactions still run; their bytes go +nowhere. +""" + +from __future__ import annotations + +__all__ = ["NullTransport"] + + +class NullTransport: + def write(self, data: bytes) -> None: + pass + + @property + def connected(self) -> bool: + return False diff --git a/python-backend/musicmouse/devices/player.py b/python-backend/musicmouse/devices/player.py index 97226eb..8f96e77 100644 --- a/python-backend/musicmouse/devices/player.py +++ b/python-backend/musicmouse/devices/player.py @@ -43,16 +43,33 @@ class Player(Protocol): def track_index(self) -> int: ... @property def current_track(self) -> Track | None: ... + @property + def position(self) -> float: + """Seconds into the current track. ``0.0`` when nothing is loaded. + + Read on demand rather than announced: a progress bar wants this twice a second, + and an event at that rate would flood the bus, the MQTT service and the log for + the benefit of one front-end. + """ + ... + + @property + def duration(self) -> float: + """Length of the current track in seconds, or ``0.0`` when unknown.""" + ... def set_playlist(self, playlist: Playlist) -> None: ... def play(self) -> None: ... def play_from_start(self) -> None: ... + def play_track(self, index: int) -> None: ... def pause(self) -> None: ... def stop(self) -> None: ... def next_track(self) -> None: ... def previous_track(self) -> None: ... + def seek(self, position: float) -> None: ... def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ... def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ... + def set_volume_limits(self, minimum: int, maximum: int) -> None: ... async def run(self) -> None: """Long-running task, if the implementation needs one.""" @@ -113,6 +130,32 @@ class PlayerBase: return None return self._playlist[self._index] + # ------------------------------------------------------------------ actions + + def set_volume(self, volume: int, *, source: EventSource = "system") -> None: + clamped = self._clamp(volume) + if clamped == self._volume: + return + self._volume = clamped + self._apply_volume(clamped) + self._announce_volume(source) + + def change_volume(self, delta: int, *, source: EventSource = "system") -> None: + self.set_volume(self._volume + delta, source=source) + + def set_volume_limits(self, minimum: int, maximum: int) -> None: + """Re-clamp to a new allowed range, and pull the current volume into it. + + Parent mode edits these while the mouse is playing, so they cannot only be + constructor arguments. + """ + self._min_volume = minimum + self._max_volume = maximum + self.set_volume(self._volume) + + def _apply_volume(self, volume: int) -> None: + """Push the new volume at whatever actually makes sound. No-op by default.""" + # ---------------------------------------------------------------- internals def _clamp(self, volume: int) -> int: @@ -181,6 +224,17 @@ class VlcPlayer(PlayerBase): self._attach_events() self._media_player.audio_set_volume(self._volume) + # -------------------------------------------------------------------- state + + @property + def position(self) -> float: + # libVLC reports -1 for both of these until a media is actually opened. + return max(0.0, float(self._media_player.get_time()) / 1000) + + @property + def duration(self) -> float: + return max(0.0, float(self._media_player.get_length()) / 1000) + # ------------------------------------------------------------------ actions def set_playlist(self, playlist: Playlist) -> None: @@ -201,10 +255,13 @@ class VlcPlayer(PlayerBase): self._list_player.play() def play_from_start(self) -> None: + self.play_track(0) + + def play_track(self, index: int) -> None: if self._playlist is None or not self._playlist: _log.warning("Nothing to play: the playlist is empty") return - self._list_player.play_item_at_index(0) + self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1))) def pause(self) -> None: self._media_player.set_pause(1) @@ -218,16 +275,11 @@ class VlcPlayer(PlayerBase): def previous_track(self) -> None: self._list_player.previous() - def set_volume(self, volume: int, *, source: EventSource = "system") -> None: - clamped = self._clamp(volume) - if clamped == self._volume: - return - self._volume = clamped - self._media_player.audio_set_volume(clamped) - self._announce_volume(source) + def seek(self, position: float) -> None: + self._media_player.set_time(int(max(0.0, position) * 1000)) - def change_volume(self, delta: int, *, source: EventSource = "system") -> None: - self.set_volume(self._volume + delta, source=source) + def _apply_volume(self, volume: int) -> None: + self._media_player.audio_set_volume(volume) async def run(self) -> None: """Poll for state libVLC does not reliably report by event.""" @@ -249,6 +301,21 @@ class VlcPlayer(PlayerBase): self._volume = volume self._announce_volume() self._set_playing(bool(self._list_player.is_playing())) + self._sync_index() + + def _sync_index(self) -> None: + """Read which track is playing back off libVLC. + + Asking the media player which media it holds works on every build; the + ``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare + int rather than a Media, and the index would then never move off zero. + """ + media = self._media_player.get_media() + if media is None: + return + index = self._mrl_to_index.get(media.get_mrl()) + if index is not None: + self._set_index(index) def _attach_events(self) -> None: vlc = self._vlc @@ -274,11 +341,6 @@ class VlcPlayer(PlayerBase): self._set_playing(False) self._announce_playlist_finished() - def _on_next_item(self, event: Any) -> None: - try: - mrl = event.u.media.get_mrl() - except AttributeError: # pragma: no cover - depends on the libVLC build - return - index = self._mrl_to_index.get(mrl) - if index is not None: - self._set_index(index) + def _on_next_item(self, _event: Any) -> None: + # The event says *when* to look; what it carries is not portable, so ignore it. + self._sync_index() diff --git a/python-backend/musicmouse/devices/wire.py b/python-backend/musicmouse/devices/wire.py index 7de6570..867b4c3 100644 --- a/python-backend/musicmouse/devices/wire.py +++ b/python-backend/musicmouse/devices/wire.py @@ -15,6 +15,7 @@ from __future__ import annotations import struct from dataclasses import dataclass, replace from enum import IntEnum +from typing import override from musicmouse.effects import ( EffectAlexaSwipeConfig, @@ -366,6 +367,7 @@ class SetButtonBrightness: button: Button brightness: float + @override def __repr__(self) -> str: return f"{self.button.slug} backlight <- {self.brightness:.2f}" diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index c6583f8..3a45a38 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -178,6 +178,20 @@ class PlayFigureRequested(IntentEvent): restart: bool = True +@dataclass(frozen=True, slots=True, kw_only=True) +class PlayAlbumRequested(IntentEvent): + """Start any album from the library, figure or not.""" + + album_id: str + track_index: int = 0 + + +@dataclass(frozen=True, slots=True, kw_only=True) +class SeekRequested(IntentEvent): + #: Seconds from the start of the current track. + position: float + + @dataclass(frozen=True, slots=True, kw_only=True) class VolumeChangeRequested(IntentEvent): delta: int diff --git a/python-backend/musicmouse/library/__init__.py b/python-backend/musicmouse/library/__init__.py new file mode 100644 index 0000000..f56d3dc --- /dev/null +++ b/python-backend/musicmouse/library/__init__.py @@ -0,0 +1,164 @@ +"""The music collection: what exists, what it is called, and what colour it is. + +The scan is the only part of this backend that touches hundreds of files, so it runs +in a worker thread and its results are cached. Everything the rest of the app sees is +plain immutable data. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path + +from musicmouse.library.analysis import ANALYZER_VERSION, Analyzer, BeatGrid, NullAnalyzer +from musicmouse.library.cache import Fingerprint, LibraryCache +from musicmouse.library.models import Album, LibraryTrack, album_id, track_key +from musicmouse.library.scanner import scan_library +from musicmouse.library.sections import SECTIONS, AlbumKind +from musicmouse.media import Playlist + +_log = logging.getLogger(__name__) + +__all__ = [ + "ANALYZER_VERSION", + "SECTIONS", + "Album", + "Analyzer", + "BeatGrid", + "LibraryCache", + "LibraryTrack", + "MusicLibrary", + "NullAnalyzer", + "album_id", + "track_key", +] + + +class MusicLibrary: + """An immutable index of albums, rebuilt wholesale rather than mutated in place.""" + + def __init__( + self, + root: Path, + cache: LibraryCache, + extensions: frozenset[str], + *, + analyzer: Analyzer | None = None, + figure_kinds: Mapping[str, AlbumKind] | None = None, + ) -> None: + self.root = root + self.cache = cache + self.extensions = extensions + self.analyzer: Analyzer = analyzer or NullAnalyzer() + #: What each figure holds. The only thing a folder name cannot say. + self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {} + self._entries: dict[str, tuple[Album, Fingerprint]] = {} + + # -------------------------------------------------------------------- reading + + @property + def albums(self) -> list[Album]: + return [album for album, _ in self._entries.values()] + + def get(self, identifier: str | None) -> Album | None: + entry = self._entries.get(identifier) if identifier else None + return entry[0] if entry else None + + def figure_playlists(self) -> dict[str, Playlist]: + """One playlist per figure folder, keyed by figure name. + + The figure path and the web path must hand the player the *same* object for the + same album: ``reactions.playback.play_figure`` resumes on an identity check. + """ + return { + album.figure: album.to_playlist() + for album, _ in self._entries.values() + if album.figure is not None + } + + def beats(self, identifier: str, index: int) -> BeatGrid | None: + album = self.get(identifier) + if album is None or not 0 <= index < len(album.tracks): + return None + return self.cache.load_beats(track_key(album.tracks[index].path)) + + # -------------------------------------------------------------------- writing + + async def refresh(self) -> None: + """Rescan from disk. Blocking work happens off the loop; the swap is atomic.""" + known = dict(self._entries) + entries = await asyncio.to_thread( + scan_library, + self.root, + self.extensions, + self.cache, + known=known, + figure_kinds=self.figure_kinds, + ) + self._entries = await asyncio.to_thread(self._with_analysis, entries) + await asyncio.to_thread(self.cache.store_index, self._entries) + + def _with_analysis( + self, entries: dict[str, tuple[Album, Fingerprint]] + ) -> dict[str, tuple[Album, Fingerprint]]: + """Fold cached analysis results into the freshly scanned index. + + Skipped entirely while ``analysis/`` is empty, which is the normal case until an + analyzer has actually been run - no point stat-ing 900 files that cannot exist. + """ + if not any(self.cache.analysis.glob("*.json")): + return entries + out: dict[str, tuple[Album, Fingerprint]] = {} + for identifier, (album, fingerprint) in entries.items(): + tracks = tuple( + replace(track, analysis=self.cache.load_analysis(track_key(track.path))) + for track in album.tracks + ) + out[identifier] = (replace(album, tracks=tracks), fingerprint) + return out + + @classmethod + async def build( + cls, + root: Path, + cache_dir: Path, + extensions: frozenset[str], + *, + analyzer: Analyzer | None = None, + figure_kinds: Mapping[str, AlbumKind] | None = None, + ) -> MusicLibrary: + cache = LibraryCache(cache_dir) + library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds) + library._entries = await asyncio.to_thread(cache.load_index) + await library.refresh() + return library + + async def analyze_pending(self) -> int: + """Run the analyzer over tracks that have no current result. + + Never called during startup: the index is what playback needs, and analysis is + minutes of DSP per track. Results are keyed by file content, so they can equally + well be produced on a faster machine and the ``analysis/`` folder copied over. + """ + analyzer = self.analyzer + if analyzer.version < ANALYZER_VERSION: + return 0 + + done = 0 + for album in self.albums: + for track in album.tracks: + key = track_key(track.path) + cached = self.cache.load_analysis(key) + if cached is not None and cached.version >= analyzer.version: + continue + analysis, grid = await asyncio.to_thread(analyzer.analyze, track.path) + if grid is not None: + self.cache.store_beats(key, grid) + self.cache.store_analysis(key, analysis) + done += 1 + if done: + _log.info("Analyzed %d tracks", done) + return done diff --git a/python-backend/musicmouse/library/analysis.py b/python-backend/musicmouse/library/analysis.py new file mode 100644 index 0000000..8f0e07e --- /dev/null +++ b/python-backend/musicmouse/library/analysis.py @@ -0,0 +1,106 @@ +"""Offline audio analysis: the seam, not the implementation. + +Nothing here computes anything yet. What it does is fix the shape of the results so +that the analyzer can arrive later without touching the scanner, the cache format, the +API contract or the frontend's data flow. + +Two rules hold the design together: + +* **Scalars travel with the index, time series do not.** A 25-minute podcast at 120 BPM + has ~3000 beats; 900 tracks of that inside ``GET /api/library`` would be tens of + megabytes. :class:`TrackAnalysis` is a handful of floats and rides along; the beat + grid lives in its own file and is fetched for the one track that is playing. +* **Every field is optional with a default.** Adding ``danceability`` later needs no + migration and no cache wipe: unknown keys on disk are dropped on load, missing ones + fall back to the default. Only :data:`ANALYZER_VERSION` moving past what a file + records marks that file stale. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, fields +from pathlib import Path +from typing import Any, Final, Protocol + +__all__ = [ + "ANALYZER_VERSION", + "Analyzer", + "BeatGrid", + "NullAnalyzer", + "TrackAnalysis", +] + +#: Bumped when an analyzer's output changes meaning. Cached results recorded under a +#: lower version are recomputed; results at or above it are left alone. +ANALYZER_VERSION: Final = 1 + + +@dataclass(frozen=True, slots=True) +class TrackAnalysis: + """What an animation may want to know about a track, in a few dozen bytes.""" + + #: The :data:`ANALYZER_VERSION` that produced this. ``0`` means "never analyzed". + version: int = 0 + tempo: float | None = None + #: 0..1 overall loudness and drive. + energy: float | None = None + #: 0..1 mood, dark and calm through bright and happy. + valence: float | None = None + #: 0..1 spectral centroid. Drives the background hue. + brightness: float | None = None + #: Whether a beat grid for this track exists on disk. + beats: bool = False + + @property + def is_analyzed(self) -> bool: + return self.version >= ANALYZER_VERSION + + def to_json(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_json(cls, data: dict[str, Any]) -> TrackAnalysis: + """Load leniently: unknown keys are dropped, missing keys take their default. + + This is what makes the cache survive an analyzer that grew a field. + """ + known = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in data.items() if k in known}) + + +@dataclass(frozen=True, slots=True) +class BeatGrid: + """Beat onsets in seconds with a 0..1 strength each, for beat-synced animation.""" + + times: tuple[float, ...] + strengths: tuple[float, ...] + + def to_json(self) -> dict[str, Any]: + # Flat pairs: half the JSON punctuation of a list of objects. + return {"beats": [x for pair in zip(self.times, self.strengths, strict=True) for x in pair]} + + @classmethod + def from_json(cls, data: dict[str, Any]) -> BeatGrid: + flat: list[float] = data["beats"] + return cls(tuple(flat[0::2]), tuple(flat[1::2])) + + +class Analyzer(Protocol): + """Turns one audio file into cacheable analysis results. + + Implementations are CPU-bound and run off the event loop. The real one will live + behind an optional dependency group so the device never installs numpy to play music. + """ + + version: int + + def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None]: ... + + +class NullAnalyzer: + """Analyzes nothing. Keeps the wiring exercised until a real analyzer lands.""" + + version = 0 + + def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None]: # noqa: ARG002 + return TrackAnalysis(), None diff --git a/python-backend/musicmouse/library/cache.py b/python-backend/musicmouse/library/cache.py new file mode 100644 index 0000000..38935a5 --- /dev/null +++ b/python-backend/musicmouse/library/cache.py @@ -0,0 +1,204 @@ +"""Where scan results live between runs. + +A directory rather than a single file, because the three kinds of content cost wildly +different amounts to produce:: + + / + ├── index.json cheap: tags and structure. Thrown away freely. + ├── covers/.jpg medium: art pulled out of an ID3 APIC frame + └── analysis/.json expensive: minutes of DSP per track + analysis/.beats.json + +That split is the whole point. A rescan must be free to rebuild ``index.json`` without +destroying analysis, so everything expensive is keyed by a *content* key (see +:func:`~musicmouse.library.models.track_key`) rather than by album id - renaming a +folder or re-sorting a section then costs nothing. +""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +from musicmouse.library.analysis import BeatGrid, TrackAnalysis +from musicmouse.library.models import Album, LibraryTrack +from musicmouse.library.sections import SECTIONS, AlbumKind + +_log = logging.getLogger(__name__) + +__all__ = ["Fingerprint", "LibraryCache"] + +#: Bump whenever the scanner's *derivation* changes - how a title, artist or series is +#: worked out - not just when the JSON shape does. A cached entry is reused whenever its +#: files are untouched, so otherwise a change to that logic is invisible until somebody +#: edits their music folder. +_INDEX_VERSION = 3 + + +@dataclass(frozen=True, slots=True) +class Fingerprint: + """What makes a folder's cached entry still valid: its files, sizes and mtimes.""" + + files: tuple[tuple[str, int, int], ...] + + @classmethod + def of(cls, paths: list[Path]) -> Fingerprint: + entries: list[tuple[str, int, int]] = [] + for path in paths: + stat = path.stat() + entries.append((path.name, stat.st_size, int(stat.st_mtime))) + return cls(tuple(entries)) + + def to_json(self) -> list[list[Any]]: + return [list(entry) for entry in self.files] + + @classmethod + def from_json(cls, data: list[list[Any]]) -> Fingerprint: + return cls(tuple((str(n), int(s), int(m)) for n, s, m in data)) + + +def _write_atomic(path: Path, payload: str) -> None: + """Write via a sibling temp file so a crash never leaves a half-written cache.""" + temp = path.with_name(f"{path.name}.tmp{os.getpid()}") + temp.write_text(payload, encoding="utf-8") + temp.replace(path) + + +class LibraryCache: + def __init__(self, directory: Path) -> None: + self.directory = directory + self.covers = directory / "covers" + self.analysis = directory / "analysis" + + def prepare(self) -> None: + for folder in (self.directory, self.covers, self.analysis): + folder.mkdir(parents=True, exist_ok=True) + + # -------------------------------------------------------------------- covers + + def cover_path(self, album_id: str) -> Path: + return self.covers / f"{album_id}.jpg" + + def store_cover(self, album_id: str, data: bytes) -> Path: + path = self.cover_path(album_id) + path.write_bytes(data) + return path + + # ------------------------------------------------------------------ analysis + + def load_analysis(self, key: str) -> TrackAnalysis | None: + path = self.analysis / f"{key}.json" + try: + return TrackAnalysis.from_json(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError): + return None + + def store_analysis(self, key: str, analysis: TrackAnalysis) -> None: + _write_atomic(self.analysis / f"{key}.json", json.dumps(analysis.to_json())) + + def load_beats(self, key: str) -> BeatGrid | None: + path = self.analysis / f"{key}.beats.json" + try: + return BeatGrid.from_json(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError, KeyError): + return None + + def store_beats(self, key: str, grid: BeatGrid) -> None: + _write_atomic(self.analysis / f"{key}.beats.json", json.dumps(grid.to_json())) + + # --------------------------------------------------------------------- index + + def load_index(self) -> dict[str, tuple[Album, Fingerprint]]: + path = self.directory / "index.json" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if raw.get("version") != _INDEX_VERSION: + _log.info("Library index is from an older version; rescanning from scratch") + return {} + + out: dict[str, tuple[Album, Fingerprint]] = {} + for entry in raw.get("albums", []): + try: + out[entry["id"]] = (_album_from_json(entry), Fingerprint.from_json(entry["files"])) + except (KeyError, TypeError, ValueError): + _log.debug("Dropping unreadable index entry %r", entry.get("id")) + return out + + def store_index(self, albums: dict[str, tuple[Album, Fingerprint]]) -> None: + payload = { + "version": _INDEX_VERSION, + "albums": [ + _album_to_json(album) | {"files": fingerprint.to_json()} + for album, fingerprint in albums.values() + ], + } + self.prepare() + _write_atomic(self.directory / "index.json", json.dumps(payload, ensure_ascii=False)) + + +# ------------------------------------------------------------------ serialisation + + +def _album_to_json(album: Album) -> dict[str, Any]: + return { + "id": album.id, + "section": album.section, + "kind": album.kind, + "title": album.title, + "artist": album.artist, + "series": album.series, + "figure": album.figure, + "colors": list(album.colors), + "folder": str(album.folder), + "cover": str(album.cover) if album.cover else None, + "tracks": [ + { + "path": str(track.path), + "title": track.title, + "duration": track.duration, + "analysis": track.analysis.to_json() if track.analysis else None, + } + for track in album.tracks + ], + } + + +def _album_from_json(data: dict[str, Any]) -> Album: + section = str(data["section"]) + if section not in SECTIONS: + raise ValueError(f"unknown section {section!r}") + # Read back what was stored rather than re-deriving it from the section: a figure's + # kind comes from the config, so the section cannot answer for it. + kind = str(data["kind"]) + if kind not in ("music", "book"): + raise ValueError(f"unknown kind {kind!r}") + red, green, blue = data["colors"] + return Album( + id=str(data["id"]), + section=section, + kind=cast("AlbumKind", kind), + title=str(data["title"]), + artist=str(data["artist"]), + series=data["series"], + figure=data["figure"], + colors=(str(red), str(green), str(blue)), + folder=Path(data["folder"]), + cover=Path(data["cover"]) if data["cover"] else None, + tracks=tuple( + LibraryTrack( + path=Path(track["path"]), + title=str(track["title"]), + duration=float(track["duration"]), + analysis=( + TrackAnalysis.from_json(track["analysis"]) if track.get("analysis") else None + ), + ) + for track in data["tracks"] + ), + ) diff --git a/python-backend/musicmouse/library/colors.py b/python-backend/musicmouse/library/colors.py new file mode 100644 index 0000000..0044c1d --- /dev/null +++ b/python-backend/musicmouse/library/colors.py @@ -0,0 +1,82 @@ +"""Three colours per album, from its cover art. + +The frontend paints an album card with these and the LED strips run an effect in them, +so a web-started album looks the same on the shelf as it does on the screen. Extraction +happens once per album ever - the result is cached next to the cover. +""" + +from __future__ import annotations + +import colorsys +import hashlib +import logging +from io import BytesIO +from typing import cast + +from musicmouse.library.models import AlbumColors + +_log = logging.getLogger(__name__) + +__all__ = ["colors_from_cover", "colors_from_id"] + +#: Quantize to this many candidates before ranking. More just returns near-duplicates. +_PALETTE_SIZE = 8 +#: Downscale first: colour proportions survive, the decode gets ~100x cheaper. +_SAMPLE_SIZE = (64, 64) + +#: Greys and near-blacks read as mud on an RGBW strip and as dirt on a card, so a +#: candidate has to clear both bars to count as one of an album's colours. +_MIN_SATURATION = 0.25 +_MIN_VALUE = 0.25 + + +def _hex(r: int, g: int, b: int) -> str: + return f"#{r:02x}{g:02x}{b:02x}" + + +def colors_from_id(album_id: str) -> AlbumColors: + """Synthesise a palette when there is no cover to take one from. + + Analogous plus complementary off one hashed hue: distinct per album, never muddy. + """ + hue = int(hashlib.sha1(album_id.encode()).hexdigest()[:4], 16) % 360 + out: list[str] = [] + for offset, saturation, value in ((0, 0.72, 0.95), (30, 0.62, 0.80), (180, 0.70, 0.90)): + r, g, b = colorsys.hsv_to_rgb(((hue + offset) % 360) / 360, saturation, value) + out.append(_hex(int(r * 255), int(g * 255), int(b * 255))) + return out[0], out[1], out[2] + + +def colors_from_cover(image_data: bytes, album_id: str) -> AlbumColors: + """Rank the cover's dominant colours, dropping the ones that would read as mud. + + Falls back to :func:`colors_from_id` for the slots that do not fill - a black-and- + white cover legitimately has no three usable colours. + """ + try: + from PIL import Image + + with Image.open(BytesIO(image_data)) as image: + sample = image.convert("RGB").resize(_SAMPLE_SIZE) + quantized = sample.quantize(colors=_PALETTE_SIZE, method=Image.Quantize.MAXCOVERAGE) + palette: list[int] = quantized.getpalette() or [] + # On a quantized ("P" mode) image getcolors() yields (count, palette index). + # Pillow's annotation covers every mode, hence the cast. + counts = cast("list[tuple[int, int]]", quantized.getcolors() or []) + except Exception: # pragma: no cover - defensive around arbitrary embedded art + _log.debug("Cover of %s could not be read; using a synthesised palette", album_id) + return colors_from_id(album_id) + + picked: list[str] = [] + for _count, index in sorted(counts, reverse=True): + r, g, b = palette[index * 3 : index * 3 + 3] + _, saturation, value = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) + if saturation < _MIN_SATURATION or value < _MIN_VALUE: + continue + picked.append(_hex(r, g, b)) + if len(picked) == 3: + return picked[0], picked[1], picked[2] + + fallback = colors_from_id(album_id) + filled = picked + list(fallback[len(picked) :]) + return filled[0], filled[1], filled[2] diff --git a/python-backend/musicmouse/library/models.py b/python-backend/musicmouse/library/models.py new file mode 100644 index 0000000..97e1c5c --- /dev/null +++ b/python-backend/musicmouse/library/models.py @@ -0,0 +1,88 @@ +"""What the browse API serves: albums of tracks, with the colours to draw them in.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path + +from musicmouse.library.analysis import TrackAnalysis +from musicmouse.library.sections import AlbumKind +from musicmouse.media import Playlist, Track + +__all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"] + +#: Primary, secondary and accent as ``"#rrggbb"`` - the format +#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new +#: parsing and the frontend gets CSS colours for free. +type AlbumColors = tuple[str, str, str] + + +def album_id(root: Path, folder: Path) -> str: + """A stable id for an album folder, from its path relative to the library root.""" + relative = folder.relative_to(root).as_posix() + return hashlib.sha1(relative.encode()).hexdigest()[:12] + + +def track_key(path: Path) -> str: + """Content key for expensive per-track results. + + Keyed on the *file*, not the album, so renaming a folder or re-sorting a section + never throws away analysis that took minutes to compute. + """ + stat = path.stat() + material = f"{path.name}:{stat.st_size}:{int(stat.st_mtime)}" + return hashlib.sha1(material.encode()).hexdigest()[:16] + + +@dataclass(frozen=True, slots=True) +class LibraryTrack: + path: Path + title: str + #: Seconds, from the tags. ``0.0`` when the file carries no duration. + duration: float = 0.0 + analysis: TrackAnalysis | None = None + + def __repr__(self) -> str: + return f"LibraryTrack({self.title!r}, {self.duration:.0f}s)" + + +@dataclass(frozen=True, slots=True) +class Album: + id: str + section: str + kind: AlbumKind + title: str + artist: str + #: Grouping key for audiobooks and podcasts. ``None`` for music, which groups by + #: artist instead - the two cases the browse UI's category view distinguishes. + series: str | None + #: Figure name when this folder sits under ``Figuren``, else ``None``. + figure: str | None + colors: AlbumColors + folder: Path + cover: Path | None + tracks: tuple[LibraryTrack, ...] + + def __len__(self) -> int: + return len(self.tracks) + + @property + def duration(self) -> float: + return sum(track.duration for track in self.tracks) + + @property + def category(self) -> str: + """How the browse view groups this album: series for books, artist for music.""" + return self.series or self.artist + + def to_playlist(self) -> Playlist: + """The player's view of this album.""" + return Playlist( + name=self.figure or self.title, + tracks=tuple(Track(track.path) for track in self.tracks), + album_id=self.id, + ) + + def __repr__(self) -> str: + return f"Album({self.title!r}, {self.artist!r}, {len(self.tracks)} tracks)" diff --git a/python-backend/musicmouse/library/scanner.py b/python-backend/musicmouse/library/scanner.py new file mode 100644 index 0000000..1c96806 --- /dev/null +++ b/python-backend/musicmouse/library/scanner.py @@ -0,0 +1,226 @@ +"""Turning folders on disk into :class:`~musicmouse.library.models.Album` objects. + +One level under each section folder, every directory holding audio files is one album. +Tags come from mutagen; where a section's tags are known to be useless the folder name +wins instead (see :mod:`musicmouse.library.sections`). Nothing here raises on bad +input - an unreadable file loses its metadata, not the boot. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from collections.abc import Mapping +from pathlib import Path + +from musicmouse.library.cache import Fingerprint, LibraryCache +from musicmouse.library.colors import colors_from_cover, colors_from_id +from musicmouse.library.models import Album, LibraryTrack, album_id +from musicmouse.library.sections import SECTIONS, AlbumKind, Section + +_log = logging.getLogger(__name__) + +__all__ = ["scan_library"] + +#: Checked in order for a cover sitting next to the audio. +_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg") + + +def _audio_files(folder: Path, extensions: frozenset[str]) -> list[Path]: + """The playable files in ``folder``, alphabetically. + + Dotfiles are skipped outright and everything else must match ``audio_extensions``, + which is what keeps a podcast folder's ``archive.json`` and its half-finished + ``.podcast-dl-*.download.tmp`` out of the playlist. + """ + return sorted( + ( + path + for path in folder.iterdir() + if path.is_file() + and not path.name.startswith(".") + and path.suffix.lower() in extensions + ), + key=lambda path: path.name, + ) + + +def _tags(path: Path) -> tuple[dict[str, str], float]: + """``(tags, duration)`` for one file. Empty and ``0.0`` when it cannot be read.""" + try: + import mutagen + + audio = mutagen.File(path, easy=True) + if audio is None: + return {}, 0.0 + tags = {key: values[0] for key, values in dict(audio).items() if values} + return tags, float(getattr(audio.info, "length", 0.0) or 0.0) + except Exception: # pragma: no cover - mutagen raises freely on damaged files + _log.debug("No readable tags in %s", path) + return {}, 0.0 + + +def _embedded_art(path: Path) -> bytes | None: + try: + import mutagen + + raw = mutagen.File(path) + if raw is None or raw.tags is None: + return None + for key in raw.tags: + if key.startswith("APIC"): + data: bytes = raw.tags[key].data + return data + except Exception: # pragma: no cover - defensive + _log.debug("No readable embedded art in %s", path) + return None + + +def _split_folder_name(name: str) -> tuple[str, str]: + """``"Conni - Conni in den Bergen"`` -> ``("Conni", "Conni in den Bergen")``.""" + artist, separator, title = name.partition(" - ") + return (artist, title) if separator else ("", name) + + +def _most_common(values: list[str]) -> str: + """The tag value most files in an album agree on, ignoring blanks.""" + counted = Counter(value for value in values if value) + return counted.most_common(1)[0][0] if counted else "" + + +def _cover_for( + folder: Path, paths: list[Path], identifier: str, cache: LibraryCache +) -> tuple[Path | None, bytes | None]: + for name in _COVER_NAMES: + candidate = folder / name + if candidate.is_file(): + return candidate, candidate.read_bytes() + for path in paths[:3]: + # Podcast feeds sometimes art only some episodes; a couple of tries is enough. + art = _embedded_art(path) + if art is not None: + return cache.store_cover(identifier, art), art + return None, None + + +def scan_album( + folder: Path, + *, + root: Path, + section_name: str, + section: Section, + extensions: frozenset[str], + cache: LibraryCache, + figure_kinds: Mapping[str, AlbumKind] | None = None, +) -> tuple[Album, Fingerprint] | None: + paths = _audio_files(folder, extensions) + if not paths: + _log.debug("No audio files in %s", folder) + return None + if section.order == "newest_first": + paths.reverse() + + fingerprint = Fingerprint.of(paths) + identifier = album_id(root, folder) + + tracks: list[LibraryTrack] = [] + albums: list[str] = [] + artists: list[str] = [] + for path in paths: + tags, duration = _tags(path) + tracks.append( + LibraryTrack(path=path, title=tags.get("title") or path.stem, duration=duration) + ) + albums.append(tags.get("album", "")) + artists.append(tags.get("albumartist") or tags.get("artist", "")) + + figure = folder.name if section.figures else None + # Every other shelf is named after what is on it. A figure folder is named after the + # figurine, so its media type has to be declared in the config. + kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind + + folder_artist, folder_title = _split_folder_name(folder.name) + + if section.title_from == "folder": + # A figure folder is named in lowercase ("fuchs"); it sits next to real album + # titles in the browse grid, so give it a capital. + title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name + else: + title = _most_common(albums) or folder_title or folder.name + + if section.artist_from == "folder": + artist = folder.name + else: + artist = _most_common(artists) or folder_artist + + cover, art = _cover_for(folder, paths, identifier, cache) + colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier) + + album = Album( + id=identifier, + section=section_name, + kind=kind, + title=title, + artist=artist, + # Books group by who or what they are about; music groups by artist. The browse + # view's category row is built straight off this, so it takes only the part + # before the first comma - an ``album_artist`` of "Bobo Siebenschlaefer, Markus + # Osterwalder, ..." is a credit list whose first name is the character. + series=artist.split(",")[0].strip() if kind == "book" else None, + figure=figure, + colors=colors, + folder=folder, + cover=cover, + tracks=tuple(tracks), + ) + return album, fingerprint + + +def scan_library( + root: Path, + extensions: frozenset[str], + cache: LibraryCache, + *, + known: dict[str, tuple[Album, Fingerprint]] | None = None, + figure_kinds: Mapping[str, AlbumKind] | None = None, +) -> dict[str, tuple[Album, Fingerprint]]: + """Scan every section under ``root``. + + ``known`` is the previously cached index: a folder whose files, sizes and mtimes are + unchanged is taken from it without a single tag being read. ``figure_kinds`` maps a + figure name to what it holds, which is the one thing the folders cannot say. + """ + cache.prepare() + known = known or {} + out: dict[str, tuple[Album, Fingerprint]] = {} + + for section_name, section in SECTIONS.items(): + section_root = root / section_name + if not section_root.is_dir(): + _log.warning("Library section %r has no folder at %s", section_name, section_root) + continue + + for folder in sorted(section_root.iterdir(), key=lambda path: path.name): + if not folder.is_dir() or folder.name.startswith("."): + continue + identifier = album_id(root, folder) + cached = known.get(identifier) + if cached is not None: + paths = _audio_files(folder, extensions) + if paths and Fingerprint.of(paths) == cached[1]: + out[identifier] = cached + continue + scanned = scan_album( + folder, + root=root, + section_name=section_name, + section=section, + extensions=extensions, + cache=cache, + figure_kinds=figure_kinds, + ) + if scanned is not None: + out[identifier] = scanned + + _log.info("Library: %d albums under %s", len(out), root) + return out diff --git a/python-backend/musicmouse/library/sections.py b/python-backend/musicmouse/library/sections.py new file mode 100644 index 0000000..f4d0be2 --- /dev/null +++ b/python-backend/musicmouse/library/sections.py @@ -0,0 +1,46 @@ +"""The four shelves of the music library, and how each one differs. + +These are folder names, not configuration. The library has one root and the layout +underneath it is fixed - a section that needed configuring would be a section whose +quirks are not understood yet. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Literal + +__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"] + +type AlbumKind = Literal["music", "book"] +type TrackOrder = Literal["filename", "newest_first"] +type TitleSource = Literal["tags", "folder"] +type ArtistSource = Literal["tags", "folder"] + + +@dataclass(frozen=True, slots=True) +class Section: + kind: AlbumKind = "music" + #: Subfolders are figure folders: their name is the figure name from the config. + figures: bool = False + order: TrackOrder = "filename" + title_from: TitleSource = "tags" + artist_from: ArtistSource = "tags" + + +#: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full +#: presenter list ("Thomas Welling, Sarah Schultes, ...") and its ``album`` tag is the +#: feed name, so neither groups usefully - the folder name is the show. And its files +#: are named ``YYYYMMDD - Title.mp3``, so reversing filename order puts the newest +#: episode first, which is the one anybody wants. +SECTIONS: Final[dict[str, Section]] = { + # A figure folder is named after the figure, and its contents are whatever that + # figure should play - often several albums' worth. The folder name is the honest + # title; the tags still supply a useful artist. + "Figuren": Section(figures=True, title_from="folder"), + "Musik": Section(), + "Hörbücher": Section(kind="book"), + "Kinderpodcasts": Section( + kind="book", order="newest_first", title_from="folder", artist_from="folder" + ), +} diff --git a/python-backend/musicmouse/media.py b/python-backend/musicmouse/media.py index 8a96b2a..621af25 100644 --- a/python-backend/musicmouse/media.py +++ b/python-backend/musicmouse/media.py @@ -1,18 +1,16 @@ """Playlist model. -Deliberately a real type rather than a bare ``list[str]``: it is what a future -browse API would serve, and it keeps track metadata in one place. +Deliberately a real type rather than a bare ``list[str]``: it is what the browse API +serves, and it keeps track metadata in one place. Building one from a folder is +:mod:`musicmouse.library`'s job. """ from __future__ import annotations -import logging from dataclasses import dataclass from pathlib import Path -_log = logging.getLogger(__name__) - -__all__ = ["Playlist", "Track", "build_playlist"] +__all__ = ["Playlist", "Track"] @dataclass(frozen=True, slots=True) @@ -31,6 +29,9 @@ class Track: class Playlist: name: str tracks: tuple[Track, ...] + #: Set when this playlist came from a library album. It is how a front-end answers + #: "what is playing?" without keeping its own copy of that state. + album_id: str | None = None def __len__(self) -> int: return len(self.tracks) @@ -43,23 +44,3 @@ class Playlist: def __repr__(self) -> str: return f"Playlist({self.name!r}, {len(self.tracks)} tracks)" - - -def build_playlist(name: str, folder: Path, extensions: tuple[str, ...]) -> Playlist: - """Collect ``folder``'s audio files into a playlist, ordered alphabetically. - - A missing or empty folder yields an empty playlist and a warning rather than an - error: an unfinished playlist should not stop the mouse from booting. - """ - if not folder.is_dir(): - _log.warning("Figure %r: no media folder at %s", name, folder) - return Playlist(name=name, tracks=()) - - suffixes = {ext.lower() for ext in extensions} - paths = sorted( - (p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in suffixes), - key=lambda p: p.name, - ) - if not paths: - _log.warning("Figure %r: no audio files in %s", name, folder) - return Playlist(name=name, tracks=tuple(Track(p) for p in paths)) diff --git a/python-backend/musicmouse/reactions/__init__.py b/python-backend/musicmouse/reactions/__init__.py index 949a11c..39afabb 100644 --- a/python-backend/musicmouse/reactions/__init__.py +++ b/python-backend/musicmouse/reactions/__init__.py @@ -11,7 +11,11 @@ Publishing button presses to Home Assistant has no reaction of its own: the MQTT service subscribes to those events directly. """ -from musicmouse.reactions import lighting, playback # noqa: F401 (import = register) +from musicmouse.reactions import ( # noqa: F401 (import = register) + lighting, + playback, + status, +) from musicmouse.reactions.registry import Reaction, on, register_all, registered __all__ = ["Reaction", "on", "register_all", "registered"] diff --git a/python-backend/musicmouse/reactions/lighting.py b/python-backend/musicmouse/reactions/lighting.py index a117abd..59f91a7 100644 --- a/python-backend/musicmouse/reactions/lighting.py +++ b/python-backend/musicmouse/reactions/lighting.py @@ -14,9 +14,10 @@ import logging from copy import deepcopy from musicmouse.app import App -from musicmouse.color import ColorRGBW +from musicmouse.color import ColorRGBW, parse_color from musicmouse.config import FigureColors from musicmouse.effects import ( + EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectReverseSwipe, EffectStaticConfig, @@ -24,6 +25,7 @@ from musicmouse.effects import ( ) from musicmouse.events import ( ActiveFigureChanged, + PlaybackChanged, PlaylistFinished, TouchButtonPressed, TouchButtonReleased, @@ -40,6 +42,12 @@ MOUSE_SWIPE_START_DEGREES = 6 / 45 * 360 MOUSE_BELL_CURVE_WIDTH = 16 SWIPE_SPEED = 180 +#: The web front-end's animation: a three-quarter arc drifting round twice a minute, in +#: the album's own primary colour. Deliberately unlike the figure swipe - the strips +#: should say *which* way the mouse was started, not just that it was. +WEB_CIRCLE_WIDTH = 270.0 # degrees +WEB_CIRCLE_SPEED = 12.0 # degrees per second + @on(ActiveFigureChanged) def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None: @@ -52,6 +60,26 @@ def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None: ) +@on(PlaybackChanged) +def web_playback(event: PlaybackChanged, app: App) -> None: + """Light the strips for playback that no figure started. + + Reacting to ``PlaybackChanged`` rather than to the play intent covers pause, stop + and playlist-end in one place. The guard is what keeps this off the figure path: + while a figure is on the reader its animation owns all three zones. + """ + if app.mouse.active_figure is not None: + return + if not event.playing: + off_animation(app) + return + + album = app.library.get(event.playlist.album_id if event.playlist else None) + if album is None: + return + web_animation(app, parse_color(album.colors[0])) + + @on(PlaylistFinished) def playlist_finished(_event: PlaylistFinished, app: App) -> None: off_animation(app) @@ -108,6 +136,16 @@ def start_animation(app: App, colors: FigureColors) -> None: app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device") +def web_animation(app: App, color: ColorRGBW) -> None: + for zone in LedZone: + app.mouse.set_effect( + zone, + EffectCircularConfig(speed=WEB_CIRCLE_SPEED, width=WEB_CIRCLE_WIDTH, color=color), + origin="device", + ) + app.mouse.set_button_brightness(app.config.general.button_leds_brightness, origin="device") + + def off_animation(app: App) -> None: _log.info("Running off animation") app.mouse.set_effect(LedZone.RING, EffectReverseSwipe(), origin="device") diff --git a/python-backend/musicmouse/reactions/playback.py b/python-backend/musicmouse/reactions/playback.py index 114849a..0d8f575 100644 --- a/python-backend/musicmouse/reactions/playback.py +++ b/python-backend/musicmouse/reactions/playback.py @@ -15,11 +15,13 @@ from musicmouse.events import ( ButtonEvent, NextTrackRequested, PauseRequested, + PlayAlbumRequested, PlayFigureRequested, PlaylistFinished, PlayRequested, PrevTrackRequested, RotaryTurned, + SeekRequested, SetVolumeRequested, VolumeChangeRequested, ) @@ -72,6 +74,21 @@ def play_figure(event: PlayFigureRequested, app: App) -> None: app.player.play_from_start() +@on(PlayAlbumRequested) +def play_album(event: PlayAlbumRequested, app: App) -> None: + """Play any album in the library. This is the web front-end's way in.""" + album = app.library.get(event.album_id) + if album is None: + _log.warning("No album %r in the library", event.album_id) + return + + # A figure album keeps the figure's own resume bookkeeping honest: it is the same + # Playlist object either way, because both come from the library index. + playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist() + app.player.set_playlist(playlist or album.to_playlist()) + app.player.play_track(event.track_index) + + @on(PlaylistFinished) def playlist_finished(_event: PlaylistFinished, app: App) -> None: # Nothing was left half-played, so the next placement starts from the top. @@ -127,6 +144,11 @@ def pause(_event: PauseRequested, app: App) -> None: app.player.pause() +@on(SeekRequested) +def seek(event: SeekRequested, app: App) -> None: + app.player.seek(event.position) + + @on(VolumeChangeRequested) def change_volume(event: VolumeChangeRequested, app: App) -> None: app.player.change_volume(event.delta, source=event.source) diff --git a/python-backend/musicmouse/reactions/status.py b/python-backend/musicmouse/reactions/status.py new file mode 100644 index 0000000..a456f0b --- /dev/null +++ b/python-backend/musicmouse/reactions/status.py @@ -0,0 +1,18 @@ +"""Remembering which links are up. + +The firmware's state is readable straight off the transport, but a broker's is not: +:class:`~musicmouse.services.mqtt.service.MqttService` announces it and then forgets. +A front-end that wants to show a connection dot needs somewhere to read it from. +""" + +from __future__ import annotations + +from musicmouse.app import App +from musicmouse.events import ConnectionChanged +from musicmouse.reactions.registry import on + + +@on(ConnectionChanged) +def connection_changed(event: ConnectionChanged, app: App) -> None: + if event.target == "mqtt": + app.state.mqtt_connected = event.connected diff --git a/python-backend/musicmouse/services/web/__init__.py b/python-backend/musicmouse/services/web/__init__.py new file mode 100644 index 0000000..203c693 --- /dev/null +++ b/python-backend/musicmouse/services/web/__init__.py @@ -0,0 +1,6 @@ +"""The web front-end: a browse-and-play UI over the same intents the buttons emit.""" + +from musicmouse.services.web.hub import StateHub +from musicmouse.services.web.service import WebService, build_app + +__all__ = ["StateHub", "WebService", "build_app"] diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py new file mode 100644 index 0000000..4a09eb7 --- /dev/null +++ b/python-backend/musicmouse/services/web/api.py @@ -0,0 +1,192 @@ +"""The HTTP surface. + +Commands are REST and state is a one-way websocket. That split keeps every control path +testable with ``curl`` and means a command needs no new machinery: it emits the same +intent the MQTT service and the buttons emit, and lands in the same reaction. + +Search is not here on purpose. The whole index goes to the browser once and filtering +happens there, which is what makes the design's type-to-search feel instant. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse + +from musicmouse.app import App +from musicmouse.events import ( + IntentEvent, + NextTrackRequested, + PauseRequested, + PlayAlbumRequested, + PlayRequested, + PrevTrackRequested, + SeekRequested, + SetVolumeRequested, +) +from musicmouse.services.web.hub import StateHub +from musicmouse.services.web.schemas import ( + AlbumOut, + BeatsOut, + LibraryOut, + PlayerStateOut, + PlayIn, + SeekIn, + SettingsIn, + SettingsOut, + VolumeIn, +) +from musicmouse.services.web.settings import ( + read_settings, + to_device_volume, + to_percent, + write_settings, +) +from musicmouse.services.web.state import snapshot + +_log = logging.getLogger(__name__) + +__all__ = ["build_router"] + +def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter: + router = APIRouter(prefix="/api") + + def emit(intent: IntentEvent) -> Response: + app.bus.emit(intent) + return Response(status_code=204) + + # ------------------------------------------------------------------- library + + @router.get("/library") + def get_library() -> LibraryOut: + return LibraryOut(albums=[AlbumOut.of(album) for album in app.library.albums]) + + @router.get("/albums/{album_id}/cover") + def get_cover(album_id: str) -> FileResponse: + album = app.library.get(album_id) + if album is None or album.cover is None: + # Not an error: the client paints the album's own colours instead. + raise HTTPException(status_code=404, detail="no cover") + return FileResponse( + album.cover, + # Cover files are content-addressed by album id and rewritten only by a + # rescan, so a long cache is safe and saves 20 requests per page load. + headers={"Cache-Control": "public, max-age=604800"}, + ) + + @router.get("/tracks/{album_id}/{index}/analysis") + def get_track_analysis(album_id: str, index: int) -> BeatsOut: + grid = app.library.beats(album_id, index) + if grid is None: + raise HTTPException(status_code=404, detail="not analyzed") + return BeatsOut(times=list(grid.times), strengths=list(grid.strengths)) + + @router.post("/library/refresh", status_code=202) + async def refresh_library() -> Response: + async def rescan() -> None: + await app.library.refresh() + app.playlists.clear() + app.playlists.update(app.library.figure_playlists()) + await hub.broadcast_library() + + # Returns immediately: a cold rescan reads tags from every file. + asyncio.create_task(rescan()) # noqa: RUF006 + return Response(status_code=202) + + # --------------------------------------------------------------------- state + + @router.get("/state") + def get_state() -> PlayerStateOut: + return snapshot(app) + + @router.websocket("/ws") + async def websocket(socket: WebSocket) -> None: + await hub.connect(socket) + try: + while True: + # Push-only. Reading is how we notice the tab closed. + await socket.receive_text() + except WebSocketDisconnect: + pass + finally: + hub.disconnect(socket) + + # ------------------------------------------------------------------ commands + + @router.post("/play", status_code=204) + def play_album(body: PlayIn) -> Response: + if app.library.get(body.album_id) is None: + raise HTTPException(status_code=404, detail="no such album") + return emit( + PlayAlbumRequested( + album_id=body.album_id, track_index=body.track_index, source="web" + ) + ) + + @router.post("/resume", status_code=204) + def resume() -> Response: + return emit(PlayRequested(source="web")) + + @router.post("/pause", status_code=204) + def pause() -> Response: + return emit(PauseRequested(source="web")) + + @router.post("/next", status_code=204) + def next_track() -> Response: + return emit(NextTrackRequested(source="web")) + + @router.post("/previous", status_code=204) + def previous_track() -> Response: + return emit(PrevTrackRequested(source="web")) + + @router.post("/seek", status_code=204) + def seek(body: SeekIn) -> Response: + return emit(SeekRequested(position=body.position, source="web")) + + @router.post("/volume", status_code=204) + def set_volume(body: VolumeIn) -> Response: + general = app.config.general + if body.percent is not None: + target = body.percent + elif body.delta_percent is not None: + target = to_percent(app.player.volume, general) + body.delta_percent + else: + raise HTTPException(status_code=422, detail="percent or delta_percent required") + return emit( + SetVolumeRequested( + volume=to_device_volume(max(0, min(100, target)), general), source="web" + ) + ) + + # ------------------------------------------------------------- parent mode + + @router.get("/settings") + def get_settings() -> SettingsOut: + return read_settings(app.config.general) + + @router.put("/settings") + async def put_settings(body: SettingsIn) -> SettingsOut: + if body.min_volume > body.max_volume: + raise HTTPException( + status_code=422, detail="min_volume must not exceed max_volume" + ) + if not body.min_volume <= body.initial_volume <= body.max_volume: + raise HTTPException( + status_code=422, detail="initial_volume must lie between min and max" + ) + + general = app.config.general + # Applied live as well as saved: a parent lowering the ceiling expects the next + # song to be quieter, not the next boot. + for key, value in body.model_dump().items(): + setattr(general, key, value) + app.player.set_volume_limits(general.min_volume, general.max_volume) + await asyncio.to_thread(write_settings, config_path, body) + await hub.broadcast_state() + return read_settings(general) + + return router diff --git a/python-backend/musicmouse/services/web/hub.py b/python-backend/musicmouse/services/web/hub.py new file mode 100644 index 0000000..526212c --- /dev/null +++ b/python-backend/musicmouse/services/web/hub.py @@ -0,0 +1,104 @@ +"""Getting state out to every open browser tab. + +State events only fire on *change*, so a tab that connects halfway through a track +would otherwise sit there knowing nothing. The fix is the one +:class:`~musicmouse.services.mqtt.service.MqttService` already uses for a reconnecting +broker: send a full snapshot on connect, then deltas. + +Position is the exception to "everything goes on the bus". A progress bar wants it +twice a second; an event at that rate would flood the queue, the MQTT service and the +log to serve one front-end. So the hub reads it straight off the player on its own +timer, and only while something is playing. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from typing import Any + +from fastapi import WebSocket + +from musicmouse.app import App +from musicmouse.events import Event, StateEvent +from musicmouse.services.web.state import snapshot + +_log = logging.getLogger(__name__) + +__all__ = ["StateHub"] + +#: Twice a second: smooth enough once the client interpolates between frames, cheap +#: enough to leave running. +POSITION_INTERVAL = 0.5 + + +class StateHub: + def __init__(self, app: App) -> None: + self.app = app + self._clients: set[WebSocket] = set() + self._unsubscribe: Any = None + + # ------------------------------------------------------------------- lifecycle + + def start(self) -> None: + self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state) + + def stop(self) -> None: + if self._unsubscribe is not None: + self._unsubscribe() + self._unsubscribe = None + + async def run(self) -> None: + """Push the playback position while anything is playing.""" + while True: + await asyncio.sleep(POSITION_INTERVAL) + if not self._clients or not self.app.player.is_playing: + continue + await self.broadcast( + { + "type": "position", + "position": self.app.player.position, + "duration": self.app.player.duration, + } + ) + + # --------------------------------------------------------------------- clients + + async def connect(self, socket: WebSocket) -> None: + await socket.accept() + self._clients.add(socket) + await self._send(socket, {"type": "state", "state": snapshot(self.app).model_dump()}) + + def disconnect(self, socket: WebSocket) -> None: + self._clients.discard(socket) + + # ------------------------------------------------------------------ publishing + + async def broadcast(self, message: dict[str, Any]) -> None: + if not self._clients: + return + payload = json.dumps(message) + for socket in list(self._clients): + try: + await socket.send_text(payload) + except Exception: + # A tab that closed mid-send is normal, not an error worth logging loudly. + _log.debug("Dropping a websocket client that went away") + self._clients.discard(socket) + + async def broadcast_state(self) -> None: + await self.broadcast({"type": "state", "state": snapshot(self.app).model_dump()}) + + async def broadcast_library(self) -> None: + await self.broadcast({"type": "library"}) + + # ------------------------------------------------------------------ internals + + async def _send(self, socket: WebSocket, message: dict[str, Any]) -> None: + with contextlib.suppress(Exception): + await socket.send_text(json.dumps(message)) + + async def _on_state(self, _event: Event) -> None: + await self.broadcast_state() diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py new file mode 100644 index 0000000..ccab9fd --- /dev/null +++ b/python-backend/musicmouse/services/web/schemas.py @@ -0,0 +1,156 @@ +"""What the browser sees. + +Two rules shape these. The whole library ships in one response, because the design's +incremental A-Z search has to be instant and ~25 albums of metadata is under 100 kB - +so no track carries anything it does not need. And volume is a *percentage* here: the +configured ceiling is a parent's business, not a child's, so it never crosses this line. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from musicmouse.library import Album +from musicmouse.library.analysis import TrackAnalysis + +__all__ = [ + "AlbumOut", + "BeatsOut", + "LibraryOut", + "PlayIn", + "PlayerStateOut", + "SeekIn", + "SettingsIn", + "SettingsOut", + "TrackOut", + "VolumeIn", +] + + +class AnalysisOut(BaseModel): + tempo: float | None = None + energy: float | None = None + valence: float | None = None + brightness: float | None = None + beats: bool = False + + @classmethod + def of(cls, analysis: TrackAnalysis | None) -> AnalysisOut | None: + if analysis is None or not analysis.is_analyzed: + return None + return cls( + tempo=analysis.tempo, + energy=analysis.energy, + valence=analysis.valence, + brightness=analysis.brightness, + beats=analysis.beats, + ) + + +class TrackOut(BaseModel): + title: str + duration: float + #: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``. + analysis: AnalysisOut | None = None + + +class AlbumOut(BaseModel): + id: str + section: str + kind: str + title: str + artist: str + series: str | None + figure: str | None + category: str + colors: list[str] + has_cover: bool + duration: float + tracks: list[TrackOut] + + @classmethod + def of(cls, album: Album) -> AlbumOut: + return cls( + id=album.id, + section=album.section, + kind=album.kind, + title=album.title, + artist=album.artist, + series=album.series, + figure=album.figure, + category=album.category, + colors=list(album.colors), + has_cover=album.cover is not None, + duration=album.duration, + tracks=[ + TrackOut( + title=track.title, + duration=track.duration, + analysis=AnalysisOut.of(track.analysis), + ) + for track in album.tracks + ], + ) + + +class LibraryOut(BaseModel): + albums: list[AlbumOut] + + +class BeatsOut(BaseModel): + times: list[float] + strengths: list[float] + + +class ConnectionOut(BaseModel): + firmware: bool + mqtt: bool + + +class PlayerStateOut(BaseModel): + playing: bool + album_id: str | None + album_title: str | None + artist: str | None + kind: str | None + track_index: int + track_title: str | None + track_count: int + position: float + duration: float + #: 0..100. The device's own range is deliberately not exposed. + volume: int + active_figure: str | None + connected: ConnectionOut + + +class PlayIn(BaseModel): + album_id: str + track_index: int = Field(default=0, ge=0) + + +class SeekIn(BaseModel): + position: float = Field(ge=0) + + +class VolumeIn(BaseModel): + """Either an absolute percentage or a relative step; exactly one of the two.""" + + percent: int | None = Field(default=None, ge=0, le=100) + delta_percent: int | None = Field(default=None, ge=-100, le=100) + + +class SettingsOut(BaseModel): + min_volume: int + max_volume: int + initial_volume: int + volume_increment: int + button_leds_brightness: float + + +class SettingsIn(BaseModel): + min_volume: int = Field(ge=0, le=200) + max_volume: int = Field(ge=0, le=200) + initial_volume: int = Field(ge=0, le=200) + volume_increment: int = Field(ge=1, le=100) + button_leds_brightness: float = Field(ge=0, le=1) diff --git a/python-backend/musicmouse/services/web/service.py b/python-backend/musicmouse/services/web/service.py new file mode 100644 index 0000000..7f9ce06 --- /dev/null +++ b/python-backend/musicmouse/services/web/service.py @@ -0,0 +1,88 @@ +"""The web front-end, as a :class:`~musicmouse.services.base.Service`. + +Everything runs on the one event loop the rest of the app already has: uvicorn's +``Server.serve()`` is a coroutine, so there is no second loop and no thread. The +service owns no state of its own - it turns HTTP into intents on the bus, and bus +events into websocket frames. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from pathlib import Path + +import uvicorn +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from musicmouse.app import App +from musicmouse.config import WebConfig +from musicmouse.services.web.api import build_router +from musicmouse.services.web.hub import StateHub + +_log = logging.getLogger(__name__) + +__all__ = ["WebService", "build_app"] + + +def build_app(app: App, config: WebConfig, config_path: Path) -> tuple[FastAPI, StateHub]: + """Assemble the ASGI app. Separate from the service so tests can drive it directly.""" + hub = StateHub(app) + api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json") + api.include_router(build_router(app, hub, config_path)) + + if config.static_dir is not None: + if config.static_dir.is_dir(): + # Mounted last and at the root so every /api route still wins; html=True + # falls back to index.html, which is what a client-side router needs. + api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web") + else: + _log.warning( + "web.static_dir %s does not exist; serving the API only " + "(run `npm run build` in web/, or drop the setting)", + config.static_dir, + ) + return api, hub + + +class WebService: + name = "web" + + def __init__(self, app: App, config: WebConfig, config_path: Path) -> None: + self.config = config + self.api, self.hub = build_app(app, config, config_path) + + async def run(self) -> None: + self.hub.start() + server = uvicorn.Server( + uvicorn.Config( + self.api, + host=self.config.host, + port=self.config.port, + # Access logs for a progress-bar poll every 500ms are noise. + access_log=False, + log_level="warning", + ) + ) + _log.info("Web front-end on http://%s:%d", self.config.host, self.config.port) + + # serve() borrows SIGINT for a graceful shutdown and then re-raises it, so + # Ctrl-C still reaches __main__'s KeyboardInterrupt handler afterwards. + position = asyncio.create_task(self.hub.run(), name="web-position") + serving = asyncio.create_task(server.serve(), name="web-serve") + try: + await asyncio.shield(serving) + except asyncio.CancelledError: + # Shutdown reaches us as a cancellation. Cancelling uvicorn mid-accept + # would leave its listening socket to the garbage collector, so ask it to + # wind down and wait for it to let the port go. + server.should_exit = True + with contextlib.suppress(asyncio.CancelledError, Exception): + await asyncio.wait_for(serving, timeout=5.0) + raise + finally: + position.cancel() + serving.cancel() + self.hub.stop() diff --git a/python-backend/musicmouse/services/web/settings.py b/python-backend/musicmouse/services/web/settings.py new file mode 100644 index 0000000..65f802e --- /dev/null +++ b/python-backend/musicmouse/services/web/settings.py @@ -0,0 +1,76 @@ +"""Volume as a percentage, and the handful of settings parent mode may change. + +Two jobs that both come down to "keep the config file's numbers out of the child's UI". + +The volume mapping lives here rather than in the frontend so that MQTT, the rotary +encoder and the firmware carry on speaking device units, unaware that a browser is +using a different scale. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from ruamel.yaml import YAML + +from musicmouse.config import GeneralConfig +from musicmouse.services.web.schemas import SettingsIn, SettingsOut + +_log = logging.getLogger(__name__) + +__all__ = ["read_settings", "to_device_volume", "to_percent", "write_settings"] + + +def to_device_volume(percent: int, general: GeneralConfig) -> int: + """Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``.""" + span = general.max_volume - general.min_volume + return general.min_volume + round(span * max(0, min(100, percent)) / 100) + + +def to_percent(volume: int, general: GeneralConfig) -> int: + """The inverse, for the state snapshot.""" + span = general.max_volume - general.min_volume + if span <= 0: + return 100 + return max(0, min(100, round((volume - general.min_volume) / span * 100))) + + +def read_settings(general: GeneralConfig) -> SettingsOut: + return SettingsOut( + min_volume=general.min_volume, + max_volume=general.max_volume, + initial_volume=general.initial_volume, + volume_increment=general.volume_increment, + button_leds_brightness=general.button_leds_brightness, + ) + + +def write_settings(path: Path, settings: SettingsIn) -> None: + """Patch the settings into ``config.yml`` in place. + + Loaded round-trip rather than with the safe loader :mod:`musicmouse.config` uses, + so the file keeps its comments, ordering and formatting - a config that explains + itself is worth more than one this could rewrite from scratch. Written through a + sibling temp file so an interrupted save cannot truncate the real one. + """ + yaml = YAML(typ="rt") + yaml.preserve_quotes = True + + with path.open(encoding="utf-8") as handle: + document = yaml.load(handle) + + general = document["general"] + for key, value in settings.model_dump().items(): + general[key] = value + + temp = path.with_name(f"{path.name}.tmp{os.getpid()}") + try: + with temp.open("w", encoding="utf-8") as handle: + yaml.dump(document, handle) + temp.replace(path) + except BaseException: + temp.unlink(missing_ok=True) + raise + _log.info("Wrote settings to %s", path) diff --git a/python-backend/musicmouse/services/web/state.py b/python-backend/musicmouse/services/web/state.py new file mode 100644 index 0000000..684cc5c --- /dev/null +++ b/python-backend/musicmouse/services/web/state.py @@ -0,0 +1,47 @@ +"""One place that answers "what is the mouse doing right now?". + +Assembled on demand rather than accumulated from events. The join between what is +playing and which figure is on the reader is the same one the MQTT player entity makes: +read ``mouse.active_figure``, do not keep a second copy of it that can drift. +""" + +from __future__ import annotations + +from musicmouse.app import App +from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut +from musicmouse.services.web.settings import to_percent + +__all__ = ["snapshot"] + + +def snapshot(app: App) -> PlayerStateOut: + player = app.player + playlist = player.playlist + album = app.library.get(playlist.album_id if playlist else None) + index = player.track_index + # The player only knows a file path, and a tagged file's name is rarely its title + # ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time. + indexed = ( + album.tracks[index] if album and 0 <= index < len(album.tracks) else None + ) + track = player.current_track + + return PlayerStateOut( + playing=player.is_playing, + album_id=album.id if album else None, + album_title=album.title if album else (playlist.name if playlist else None), + artist=album.artist if album else None, + kind=album.kind if album else None, + track_index=index, + track_title=indexed.title if indexed else (track.title if track else None), + track_count=len(playlist) if playlist else 0, + position=player.position, + # Likewise the duration: the player only has one once libVLC has opened the file. + duration=(indexed.duration if indexed else 0.0) or player.duration, + volume=to_percent(player.volume, app.config.general), + active_figure=app.mouse.active_figure, + connected=ConnectionOut( + firmware=app.mouse.connected, + mqtt=app.state.mqtt_connected, + ), + ) diff --git a/python-backend/musicmouse/simulator/driver.py b/python-backend/musicmouse/simulator/driver.py index 53d0ef9..d7e713a 100644 --- a/python-backend/musicmouse/simulator/driver.py +++ b/python-backend/musicmouse/simulator/driver.py @@ -24,10 +24,12 @@ from musicmouse.events import ( ButtonEvent, NextTrackRequested, PauseRequested, + PlayAlbumRequested, PlayRequested, PrevTrackRequested, RfidTokenRead, RotaryTurned, + SeekRequested, SetVolumeRequested, TouchButtonPressed, TouchButtonReleased, @@ -169,6 +171,25 @@ class SimulatorDriver: self.app.bus.emit(SetVolumeRequested(volume=volume, source="simulator")) await self.settle() + async def play_album(self, title: str, track_index: int = 0) -> None: + """Start a library album by title - what the web front-end does.""" + album = next( + (a for a in self.app.library.albums if a.title.lower() == title.lower()), None + ) + if album is None: + known = ", ".join(sorted(a.title for a in self.app.library.albums)) + raise ScriptError(f"unknown album {title!r} (known: {known})") + self.app.bus.emit( + PlayAlbumRequested( + album_id=album.id, track_index=track_index, source="simulator" + ) + ) + await self.settle() + + async def seek(self, position: float) -> None: + self.app.bus.emit(SeekRequested(position=position, source="simulator")) + await self.settle() + # -------------------------------------------------------------------- time async def wait(self, seconds: float) -> None: @@ -213,6 +234,13 @@ class SimulatorDriver: return self.app.mouse.active_figure or "none" case "playlist": return player.playlist.name if player.playlist else "none" + case "album": + album = self.app.library.get( + player.playlist.album_id if player.playlist else None + ) + return album.title if album else "none" + case "position": + return f"{player.position:.1f}" case "track": return str(player.track_index) case "title": @@ -227,8 +255,8 @@ class SimulatorDriver: return type(effect).__name__ if effect is not None else "none" case _: raise ScriptError( - f"unknown property {key!r} (try: playing, figure, playlist, track, " - f"title, volume, brightness, ring, mouse, shelf)" + f"unknown property {key!r} (try: playing, figure, playlist, album, " + f"track, title, position, volume, brightness, ring, mouse, shelf)" ) # ------------------------------------------------------------------ scripts @@ -275,6 +303,10 @@ class SimulatorDriver: await self.emit_pause() case ("volume", [level]): await self.set_volume(_int(level)) + case ("album", [*words]) if words: + await self.play_album(" ".join(words)) + case ("seek", [position]): + await self.seek(Duration.parse(position).seconds) case ("disconnect", []): await self.disconnect() case ("reconnect", []): diff --git a/python-backend/musicmouse/simulator/fake_player.py b/python-backend/musicmouse/simulator/fake_player.py index 5bda7ae..757a885 100644 --- a/python-backend/musicmouse/simulator/fake_player.py +++ b/python-backend/musicmouse/simulator/fake_player.py @@ -14,7 +14,6 @@ import logging from musicmouse.bus import EventBus from musicmouse.clock import Clock, RealClock from musicmouse.devices.player import PlayerBase -from musicmouse.events import EventSource from musicmouse.media import Playlist _log = logging.getLogger(__name__) @@ -45,6 +44,20 @@ class FakePlayer(PlayerBase): self._timer: asyncio.Task[None] | None = None self.closed = False + # -------------------------------------------------------------------- state + + @property + def position(self) -> float: + """Derived from the clock, so it is exact under ``FakeClock`` too.""" + remaining = self._remaining + if self._started_at is not None: + remaining = max(0.0, remaining - (self._clock.now() - self._started_at)) + return max(0.0, self.track_duration - remaining) + + @property + def duration(self) -> float: + return self.track_duration if self._playlist else 0.0 + # ------------------------------------------------------------------ actions def set_playlist(self, playlist: Playlist) -> None: @@ -64,11 +77,14 @@ class FakePlayer(PlayerBase): self._start_timer() def play_from_start(self) -> None: + self.play_track(0) + + def play_track(self, index: int) -> None: if self._playlist is None or not self._playlist: _log.warning("Nothing to play: the playlist is empty") return self._cancel_timer() - self._set_index(0) + self._set_index(max(0, min(index, len(self._playlist) - 1))) self._remaining = self.track_duration self._set_playing(True) self._start_timer() @@ -90,15 +106,12 @@ class FakePlayer(PlayerBase): def previous_track(self) -> None: self._skip(-1) - def set_volume(self, volume: int, *, source: EventSource = "system") -> None: - clamped = self._clamp(volume) - if clamped == self._volume: + def seek(self, position: float) -> None: + if self._playlist is None: return - self._volume = clamped - self._announce_volume(source) - - def change_volume(self, delta: int, *, source: EventSource = "system") -> None: - self.set_volume(self._volume + delta, source=source) + self._remaining = max(0.0, self.track_duration - max(0.0, position)) + if self._playing: + self._start_timer() async def run(self) -> None: return diff --git a/python-backend/musicmouse/simulator/harness.py b/python-backend/musicmouse/simulator/harness.py index 34058bd..b762fb2 100644 --- a/python-backend/musicmouse/simulator/harness.py +++ b/python-backend/musicmouse/simulator/harness.py @@ -12,8 +12,9 @@ from dataclasses import dataclass from musicmouse.app import App from musicmouse.bus import EventBus from musicmouse.clock import Clock, FakeClock -from musicmouse.config import Config, build_playlists +from musicmouse.config import Config from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.library import MusicLibrary from musicmouse.reactions import register_all from musicmouse.simulator.driver import SimulatorDriver from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer @@ -41,6 +42,7 @@ async def build_simulation( *, clock: Clock | None = None, track_duration: float = DEFAULT_TRACK_DURATION, + library: MusicLibrary | None = None, ) -> Simulation: bus = EventBus() await bus.start() @@ -59,12 +61,22 @@ async def build_simulation( **FakePlayer.volume_kwargs(config.general), ) + if library is None: + library_config = config.general.library + library = await MusicLibrary.build( + library_config.root, + library_config.cache, + frozenset(config.general.audio_extensions), + figure_kinds=config.figure_kinds, + ) + app = App( config=config, bus=bus, mouse=mouse, player=player, - playlists=build_playlists(config), + library=library, + playlists=library.figure_playlists(), clock=clock, ) register_all(bus, app) diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml index a9679a3..ba45413 100644 --- a/python-backend/pyproject.toml +++ b/python-backend/pyproject.toml @@ -5,14 +5,22 @@ description = "Host backend for the MusicMouse RFID music player" requires-python = ">=3.13" dependencies = [ "aiomqtt>=2.0", + "fastapi>=0.115", + "mutagen>=1.47", + "pillow>=10.4", "pydantic>=2.7", "pyserial-asyncio>=0.6", "python-vlc>=3.0", "ruamel.yaml>=0.18", + "uvicorn>=0.30", + # uvicorn ships no websocket implementation of its own; without this the + # state-push upgrade is answered with a 404 and the UI never updates. + "websockets>=13", ] [project.optional-dependencies] -dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"] +# httpx2 is what starlette.testclient wants now; plain httpx is deprecated there. +dev = ["httpx2>=2.12", "mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"] [project.scripts] musicmouse = "musicmouse.__main__:main" diff --git a/python-backend/scenarios/web_play.txt b/python-backend/scenarios/web_play.txt new file mode 100644 index 0000000..9011ac6 --- /dev/null +++ b/python-backend/scenarios/web_play.txt @@ -0,0 +1,52 @@ +# The web front-end's path: any album, not just the five with a figure. +# Run it by hand with: +# python -m musicmouse --config --simulate --script scenarios/web_play.txt + +expect playing false +expect ring none + +# Starting an album that no figure owns lights the strips differently from a figure - +# a slow three-quarter circle in the album's own colour, so the shelf shows which way +# the mouse was started. +album Kinderparty Lieder +expect playing true +expect album Kinderparty Lieder +expect track 0 +expect ring EffectCircularConfig +expect mouse EffectCircularConfig +expect shelf EffectCircularConfig +expect brightness 0.50 + +# Transport works the same whoever asked for it. +next +expect track 1 + +wait 6s +expect playing false +expect ring EffectReverseSwipe + +# A figure still wins: its animation owns the strips while it is on the reader. +place fuchs +expect figure fuchs +expect album Fuchs +expect ring EffectSwipeAndChange + +# ...and the web animation does not fight it. +album Eule +expect album Eule +expect ring EffectSwipeAndChange + +remove +expect playing false + +# Starting a figure's album from the web is the same playlist the figure plays. +album Fuchs +expect playlist fuchs +expect playing true + +seek 2s +expect position 2.0 + +pause +expect playing false +expect ring EffectReverseSwipe diff --git a/python-backend/tests/conftest.py b/python-backend/tests/conftest.py index f294df0..ac4d5ce 100644 --- a/python-backend/tests/conftest.py +++ b/python-backend/tests/conftest.py @@ -6,29 +6,93 @@ from typing import Any import pytest from ruamel.yaml import YAML +#: Half a second of silence, so ``mutagen`` reports a real duration and tags can be +#: written onto a file that is actually an MP3. Generated once with ffmpeg. +SILENCE = Path(__file__).parent / "data" / "silence.mp3" + VALID_CONFIG: dict[str, Any] = { "general": { - "figure_folder": "music", + "library": {"root": "music", "cache": ".cache"}, "serial_port": "/dev/ttyUSB0", + "alsa_device": "simulate", "min_volume": 0, "max_volume": 60, "initial_volume": 40, }, "figures": { "fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]}, - "eule": {"id": "04b2c3d4e5", "colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"]}, + # One of each, so the figure-kind branch is exercised by every fixture. + "eule": { + "id": "04b2c3d4e5", + "colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"], + "kind": "book", + }, }, } +def write_track(path: Path, **tags: str) -> Path: + """Copy the silence fixture to ``path`` and stamp the given easy-mode ID3 tags.""" + import mutagen + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(SILENCE.read_bytes()) + if tags: + audio = mutagen.File(path, easy=True) + if audio.tags is None: + audio.add_tags() + for key, value in tags.items(): + audio[key] = value + audio.save() + return path + + @pytest.fixture def config_dir(tmp_path: Path) -> Path: - """A directory with a valid ``music/`` tree and two figures' worth of tracks.""" + """A directory holding a small but structurally real music library. + + Two figure folders (matching ``VALID_CONFIG``), one music album, one audiobook and + one podcast - enough for every branch the scanner has. + """ + root = tmp_path / "music" + for figure, tracks in (("fuchs", 3), ("eule", 2)): - folder = tmp_path / "music" / figure - folder.mkdir(parents=True) for index in range(tracks): - (folder / f"{index:02d} - track.mp3").write_bytes(b"") + write_track( + root / "Figuren" / figure / f"{index:02d} - track.mp3", + title=f"Track {index}", + album="Waldlieder", + albumartist="Die Tiere", + ) + + for index in range(2): + write_track( + root / "Musik" / "Kinderparty - Kinderparty Lieder" / f"{index:02d} - lied.mp3", + title=f"Lied {index}", + album="Kinderparty Lieder", + albumartist="Kinderparty", + ) + + for index in range(2): + write_track( + root / "Hörbücher" / "Conni - Conni in den Bergen" / f"{index:02d} - teil.mp3", + title=f"Teil {index}", + album="Conni in den Bergen", + albumartist="Conni, Julia Boehme", + ) + + podcast = root / "Kinderpodcasts" / "Wissen macht Ah" + for date, title in (("20240101", "Alt"), ("20260101", "Neu")): + write_track( + podcast / f"{date} - {title}.mp3", + title=title, + album="Wissen macht Ah! - Podcast", + albumartist="Ein Name, Noch Einer, Und Einer", + ) + # The two kinds of scratch file a podcast downloader leaves behind. + (podcast / "archive.json").write_text("[]") + (podcast / ".podcast-dl-abc.download.tmp").write_bytes(b"") + return tmp_path diff --git a/python-backend/tests/data/silence.mp3 b/python-backend/tests/data/silence.mp3 new file mode 100644 index 0000000..6b231ea Binary files /dev/null and b/python-backend/tests/data/silence.mp3 differ diff --git a/python-backend/tests/test_config.py b/python-backend/tests/test_config.py index a82293c..aa6965a 100644 --- a/python-backend/tests/test_config.py +++ b/python-backend/tests/test_config.py @@ -7,7 +7,7 @@ from typing import Any import pytest from musicmouse.color import ColorRGBW, parse_color -from musicmouse.config import ConfigError, build_playlists, load_config +from musicmouse.config import ConfigError, load_config from tests.conftest import VALID_CONFIG, write_config @@ -27,57 +27,29 @@ def test_loads_valid_config(config_dir: Path) -> None: assert config.general.max_volume == 60 -def test_figure_folder_resolves_relative_to_the_config_file(config_dir: Path) -> None: +def test_library_root_resolves_relative_to_the_config_file(config_dir: Path) -> None: config = load_config(write_config(config_dir, VALID_CONFIG)) - assert config.general.figure_folder == (config_dir / "music").resolve() + assert config.general.library.root == (config_dir / "music").resolve() + assert config.general.library.figure_folder == (config_dir / "music" / "Figuren").resolve() + assert config.folder_for("fuchs").name == "fuchs" -def test_tag_map_and_playlists(config_dir: Path) -> None: +def test_tag_map(config_dir: Path) -> None: config = load_config(write_config(config_dir, VALID_CONFIG)) - assert config.tag_map == { bytes.fromhex("04a1b2c3d4"): "fuchs", bytes.fromhex("04b2c3d4e5"): "eule", } - playlists = build_playlists(config) - assert [t.path.name for t in playlists["fuchs"].tracks] == [ - "00 - track.mp3", - "01 - track.mp3", - "02 - track.mp3", - ] - assert len(playlists["eule"]) == 2 -def test_playlist_is_alphabetical_regardless_of_creation_order(config_dir: Path) -> None: - folder = config_dir / "music" / "fuchs" - for name in ("zz last.mp3", "aa first.mp3"): - (folder / name).write_bytes(b"") - - playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG))) - names = [t.path.name for t in playlists["fuchs"].tracks] - assert names == sorted(names) - assert names[0] == "00 - track.mp3" - - -def test_non_audio_files_are_ignored(config_dir: Path) -> None: - (config_dir / "music" / "eule" / "cover.jpg").write_bytes(b"") - (config_dir / "music" / "eule" / "notes.txt").write_bytes(b"") - - playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG))) - assert len(playlists["eule"]) == 2 - - -def test_missing_figure_folder_warns_but_does_not_fail( - config_dir: Path, caplog: pytest.LogCaptureFixture -) -> None: - data = copy.deepcopy(VALID_CONFIG) - data["figures"]["neu"] = {"id": "0400000001", "colors": ["#111111"] * 4} +def test_web_section_is_optional(config_dir: Path) -> None: + assert load_config(write_config(config_dir, VALID_CONFIG)).general.web is None + data = _config(web={"port": 9000, "static_dir": "dist"}) config = load_config(write_config(config_dir, data)) - playlists = build_playlists(config) - - assert len(playlists["neu"]) == 0 - assert "no media folder" in caplog.text + assert config.general.web is not None + assert config.general.web.port == 9000 + assert config.general.web.static_dir == (config_dir / "dist").resolve() # --------------------------------------------------------------------- error paths @@ -147,9 +119,9 @@ def test_initial_volume_must_lie_in_range(config_dir: Path) -> None: assert "must lie between" in message -def test_missing_figure_folder_is_an_error(config_dir: Path) -> None: - message = _error(config_dir, _config(figure_folder="does-not-exist")) - assert "general.figure_folder" in message +def test_missing_library_root_is_an_error(config_dir: Path) -> None: + message = _error(config_dir, _config(library={"root": "does-not-exist"})) + assert "general.library.root" in message assert "no such directory" in message diff --git a/python-backend/tests/test_library.py b/python-backend/tests/test_library.py new file mode 100644 index 0000000..0985b3c --- /dev/null +++ b/python-backend/tests/test_library.py @@ -0,0 +1,309 @@ +"""The library scan: what ends up in the index, and what stays out of it.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config +from musicmouse.library import Album, MusicLibrary +from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis +from musicmouse.library.cache import LibraryCache +from musicmouse.library.colors import colors_from_id +from musicmouse.library.models import album_id +from tests.conftest import VALID_CONFIG, write_config, write_track + +EXTENSIONS = frozenset(DEFAULT_AUDIO_EXTENSIONS) + + +async def build(config_dir: Path) -> MusicLibrary: + config = load_config(write_config(config_dir, VALID_CONFIG)) + return await MusicLibrary.build( + config.general.library.root, + config.general.library.cache, + EXTENSIONS, + figure_kinds=config.figure_kinds, + ) + + +def album_named(library: MusicLibrary, title: str) -> Album: + return next(album for album in library.albums if album.title == title) + + +# ------------------------------------------------------------------------ structure + + +async def test_every_section_is_scanned(config_dir: Path) -> None: + library = await build(config_dir) + assert {album.title for album in library.albums} == { + "Fuchs", + "Eule", + "Kinderparty Lieder", + "Conni in den Bergen", + "Wissen macht Ah", + } + + +async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> None: + library = await build(config_dir) + playlists = library.figure_playlists() + + assert set(playlists) == {"fuchs", "eule"} + assert len(playlists["fuchs"]) == 3 + assert [track.path.name for track in playlists["fuchs"].tracks] == [ + "00 - track.mp3", + "01 - track.mp3", + "02 - track.mp3", + ] + # The playlist carries the album it came from, which is how a front-end answers + # "what is playing?" without keeping its own copy of that state. + assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id + + +async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Kinderparty Lieder") + assert album.kind == "music" + assert album.artist == "Kinderparty" + # Music groups by artist, so it has no series. + assert album.series is None + assert album.category == "Kinderparty" + + +async def test_audiobooks_group_by_the_name_before_the_comma(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Conni in den Bergen") + assert album.kind == "book" + # album_artist is a credit list; only the first name is the character. + assert album.artist == "Conni, Julia Boehme" + assert album.series == "Conni" + assert album.category == "Conni" + + +async def test_podcasts_are_named_after_the_folder_not_the_tags(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Wissen macht Ah") + # The tags say album="Wissen macht Ah! - Podcast" and artist=. + assert album.kind == "book" + assert album.artist == "Wissen macht Ah" + assert album.series == "Wissen macht Ah" + + +async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Wissen macht Ah") + assert [track.title for track in album.tracks] == ["Neu", "Alt"] + + +async def test_other_sections_keep_filename_order(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Kinderparty Lieder") + assert [track.path.name for track in album.tracks] == [ + "00 - lied.mp3", + "01 - lied.mp3", + ] + + +async def test_figures_are_marked_and_others_are_not(config_dir: Path) -> None: + library = await build(config_dir) + assert album_named(library, "Fuchs").figure == "fuchs" + assert album_named(library, "Kinderparty Lieder").figure is None + + +async def test_durations_come_from_the_files(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Eule") + assert all(track.duration > 0 for track in album.tracks) + assert album.duration == pytest.approx(sum(t.duration for t in album.tracks)) + + +# -------------------------------------------------------------------- what is skipped + + +async def test_scratch_files_never_reach_a_playlist(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Wissen macht Ah") + names = {track.path.name for track in album.tracks} + assert "archive.json" not in names + assert not any(name.startswith(".") for name in names) + assert len(album.tracks) == 2 + + +async def test_unknown_top_level_folders_are_ignored(config_dir: Path) -> None: + stray = config_dir / "music" / "Sonstiges" / "Irgendwas" + write_track(stray / "01 - x.mp3", title="X") + + library = await build(config_dir) + assert not any(album.section == "Sonstiges" for album in library.albums) + + +async def test_a_missing_section_warns_rather_than_failing( + config_dir: Path, caplog: pytest.LogCaptureFixture +) -> None: + import shutil + + shutil.rmtree(config_dir / "music" / "Musik") + library = await build(config_dir) + + assert "Musik" in caplog.text + assert len(library.albums) == 4 + + +async def test_an_empty_album_folder_is_not_an_album(config_dir: Path) -> None: + (config_dir / "music" / "Musik" / "Leer").mkdir() + library = await build(config_dir) + assert not any(album.title == "Leer" for album in library.albums) + + +# ------------------------------------------------------------------------- colours + + +async def test_every_album_has_three_colours(config_dir: Path) -> None: + library = await build(config_dir) + for album in library.albums: + assert len(album.colors) == 3 + assert all(colour.startswith("#") and len(colour) == 7 for colour in album.colors) + + +async def test_colours_are_synthesised_when_there_is_no_cover(config_dir: Path) -> None: + album = album_named(await build(config_dir), "Fuchs") + assert album.cover is None + assert album.colors == colors_from_id(album.id) + + +async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None: + from PIL import Image + + folder = config_dir / "music" / "Musik" / "Kinderparty - Kinderparty Lieder" + Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg") + + album = album_named(await build(config_dir), "Kinderparty Lieder") + assert album.cover == folder / "cover.jpg" + # A solid red cover has one usable colour; the rest fall back to the synthesised + # palette rather than repeating it. + assert album.colors[0] != colors_from_id(album.id)[0] + + +# --------------------------------------------------------------------------- cache + + +async def test_a_second_build_reuses_the_index(config_dir: Path) -> None: + first = await build(config_dir) + second = await build(config_dir) + assert {a.id for a in first.albums} == {a.id for a in second.albums} + assert (config_dir / ".cache" / "index.json").is_file() + + +async def test_a_changed_folder_is_rescanned(config_dir: Path) -> None: + library = await build(config_dir) + assert len(album_named(library, "Eule").tracks) == 2 + + write_track(config_dir / "music" / "Figuren" / "eule" / "99 - neu.mp3", title="Neu") + + library = await build(config_dir) + assert len(album_named(library, "Eule").tracks) == 3 + + +async def test_a_rescan_leaves_analysis_alone(config_dir: Path) -> None: + """The whole reason the cache is a directory rather than one file.""" + library = await build(config_dir) + album = album_named(library, "Eule") + from musicmouse.library.models import track_key + + key = track_key(album.tracks[0].path) + library.cache.store_analysis(key, TrackAnalysis(version=ANALYZER_VERSION, tempo=128.0)) + library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5))) + + # Force a full rescan by dropping the cheap part of the cache. + (config_dir / ".cache" / "index.json").unlink() + library = await build(config_dir) + + album = album_named(library, "Eule") + assert album.tracks[0].analysis is not None + assert album.tracks[0].analysis.tempo == 128.0 + grid = library.beats(album.id, 0) + assert grid is not None + assert grid.times == (0.5, 1.0) + + +async def test_analysis_survives_an_analyzer_that_grew_a_field(tmp_path: Path) -> None: + cache = LibraryCache(tmp_path / "cache") + cache.prepare() + (cache.analysis / "abc.json").write_text( + json.dumps({"version": 1, "tempo": 90.0, "danceability": 0.7}) + ) + + analysis = cache.load_analysis("abc") + assert analysis is not None + assert analysis.tempo == 90.0 + + +async def test_a_corrupt_index_is_rebuilt_rather_than_fatal(config_dir: Path) -> None: + await build(config_dir) + (config_dir / ".cache" / "index.json").write_text("{ not json") + + library = await build(config_dir) + assert len(library.albums) == 5 + + +def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None: + first = album_id(tmp_path, tmp_path / "Musik" / "Ein Album") + second = album_id(tmp_path, tmp_path / "Musik" / "Ein Album") + other = album_id(tmp_path, tmp_path / "Musik" / "Ein Anderes") + assert first == second + assert first != other + + +# -------------------------------------------------------------------- figure kinds + + +async def test_a_figure_is_music_unless_the_config_says_otherwise( + config_dir: Path, +) -> None: + """A figure folder is named after the figurine, so nothing on disk says what it is.""" + library = await build(config_dir) + assert album_named(library, "Fuchs").kind == "music" + assert album_named(library, "Eule").kind == "book" + + +async def test_a_book_figure_groups_with_the_audiobooks(config_dir: Path) -> None: + """The bug this fixed: a figure holding an audiobook landed in its artist's shelf + as music, so the category showed "4 Alben" and was drawn like one.""" + for index in range(2): + write_track( + config_dir / "music" / "Figuren" / "eule" / f"{index:02d} - track.mp3", + title=f"Teil {index}", + album="Conni in den Bergen", + albumartist="Conni, Julia Boehme", + ) + library = await build(config_dir) + + figure = album_named(library, "Eule") + assert figure.kind == "book" + # Books group by series, which a music album does not have at all. + assert figure.series == "Conni" + assert figure.category == "Conni" + + conni = [album for album in library.albums if album.category == "Conni"] + assert len(conni) == 2 + assert {album.kind for album in conni} == {"book"} + + +async def test_a_music_figure_has_no_series(config_dir: Path) -> None: + figure = album_named(await build(config_dir), "Fuchs") + assert figure.series is None + assert figure.category == "Die Tiere" + + +async def test_a_figures_kind_survives_the_cache(config_dir: Path) -> None: + """It cannot be re-derived on load: the section says "Figuren", not what is in it. + + The index used to rebuild `kind` from the section, so a book figure came back as + music on the second boot - and the browse view drew it square. + """ + first = await build(config_dir) + assert album_named(first, "Eule").kind == "book" + assert (config_dir / ".cache" / "index.json").is_file() + + # Nothing on disk changed, so this run is served entirely from the index. + second = await build(config_dir) + eule = album_named(second, "Eule") + assert eule.kind == "book" + assert eule.series is not None + assert album_named(second, "Fuchs").kind == "music" + assert album_named(second, "Fuchs").series is None diff --git a/python-backend/tests/test_player.py b/python-backend/tests/test_player.py index 589bd78..e4a2c1f 100644 --- a/python-backend/tests/test_player.py +++ b/python-backend/tests/test_player.py @@ -1,7 +1,9 @@ """Tests for the shared player behaviour, exercised through FakePlayer. VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio -device and is covered by the on-device checklist, not here. +device and is covered by the on-device checklist, not here - except for how it works +out which track is playing, which is stubbed out at the bottom of this file because +getting it wrong is invisible until a front-end displays it. """ from __future__ import annotations @@ -13,7 +15,7 @@ import pytest from musicmouse.bus import EventBus from musicmouse.clock import FakeClock -from musicmouse.devices.player import Player, VlcPlayer +from musicmouse.devices.player import Player, PlayerBase, VlcPlayer from musicmouse.events import ( Event, PlaybackChanged, @@ -295,3 +297,69 @@ def test_fake_player_satisfies_the_player_protocol(player: FakePlayer) -> None: def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player: """Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate.""" return real + + +# --------------------------------------------------------- track index without libVLC + + +class _FakeMedia: + def __init__(self, mrl: str) -> None: + self._mrl = mrl + + def get_mrl(self) -> str: + return self._mrl + + +class _FakeMediaPlayer: + """Just enough of libVLC's media player to exercise index syncing.""" + + def __init__(self) -> None: + self.media: _FakeMedia | None = None + + def get_media(self) -> _FakeMedia | None: + return self.media + + +def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer: + """A VlcPlayer with its libVLC parts stubbed out, without calling __init__.""" + player = object.__new__(VlcPlayer) + PlayerBase.__init__(player, bus) + player._media_player = _FakeMediaPlayer() # type: ignore[assignment] + player._playlist = playlist + player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)} + return player + + +async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> None: + """Not taken from the event payload. + + ``MediaListPlayerNextItemSet`` carries a bare int rather than a Media on some + libVLC builds, and trusting it left the index pinned at zero: the UI showed track 1 + of an album that was audibly on track 3. + """ + playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3))) + player = _vlc_like(bus, playlist) + events: list[Event] = [] + bus.subscribe(TrackChanged, events.append) + + player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined] + player._on_next_item(object()) + await bus.drain() + + assert player.track_index == 2 + assert player.current_track is not None + assert player.current_track.path.name == "2.mp3" + assert len(events) == 1 + + +async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None: + playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3))) + player = _vlc_like(bus, playlist) + + player._media_player.media = None # type: ignore[attr-defined] + player._on_next_item(object()) + player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined] + player._on_next_item(object()) + await bus.drain() + + assert player.track_index == 0 diff --git a/python-backend/tests/test_settings.py b/python-backend/tests/test_settings.py new file mode 100644 index 0000000..be398bf --- /dev/null +++ b/python-backend/tests/test_settings.py @@ -0,0 +1,176 @@ +"""Parent mode: the small set of settings the UI may change, and how they are saved.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import httpx2 +import pytest + +from musicmouse.config import WebConfig, load_config +from musicmouse.services.web.service import build_app +from musicmouse.services.web.settings import to_device_volume, to_percent +from musicmouse.simulator.harness import Simulation, build_simulation +from tests.conftest import VALID_CONFIG, write_config + +SETTINGS = { + "min_volume": 0, + "max_volume": 60, + "initial_volume": 40, + "volume_increment": 5, + "button_leds_brightness": 0.5, +} + +#: A config file with comments, to prove saving does not flatten them. +COMMENTED = """\ +# The MusicMouse config. +general: + library: + root: music # where the shelves live + cache: .cache + serial_port: "/dev/ttyUSB0" + alsa_device: simulate # no sound from the test suite, please + # Volume, 0..100. + min_volume: 0 + max_volume: 60 + initial_volume: 40 + +figures: + fuchs: + id: "04a1b2c3d4" + colors: ["#ff6600", "#ffcc00", "#331100", "wff"] + eule: + id: "04b2c3d4e5" + colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"] +""" + + +@pytest.fixture +async def sim(config_dir: Path) -> AsyncIterator[Simulation]: + config = load_config(write_config(config_dir, VALID_CONFIG)) + simulation = await build_simulation(config) + try: + yield simulation + finally: + await simulation.aclose() + + +@pytest.fixture +async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]: + path = config_dir / "config.yml" + path.write_text(COMMENTED, encoding="utf-8") + api, hub = build_app(sim.app, WebConfig(), path) + hub.start() + transport = httpx2.ASGITransport(app=api) + try: + async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http: + yield http + finally: + hub.stop() + + +# ------------------------------------------------------------------------- mapping + + +def test_full_percent_is_the_configured_ceiling(config_dir: Path) -> None: + general = load_config(write_config(config_dir, VALID_CONFIG)).general + assert to_device_volume(100, general) == 60 + assert to_device_volume(0, general) == 0 + assert to_device_volume(50, general) == 30 + + +def test_percent_round_trips(config_dir: Path) -> None: + general = load_config(write_config(config_dir, VALID_CONFIG)).general + for percent in (0, 25, 50, 75, 100): + assert to_percent(to_device_volume(percent, general), general) == percent + + +def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None: + """min == max is a legal config; it must not divide by zero.""" + general = load_config(write_config(config_dir, VALID_CONFIG)).general + general.min_volume = general.max_volume = 40 + assert to_percent(40, general) == 100 + assert to_device_volume(50, general) == 40 + + +# -------------------------------------------------------------------------- reading + + +async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None: + body = (await client.get("/api/settings")).json() + assert set(body) == set(SETTINGS) + assert body["max_volume"] == 60 + + +# -------------------------------------------------------------------------- writing + + +async def test_saving_keeps_the_file_readable_and_commented( + client: httpx2.AsyncClient, config_dir: Path +) -> None: + response = await client.put("/api/settings", json={**SETTINGS, "max_volume": 45}) + assert response.status_code == 200 + + text = (config_dir / "config.yml").read_text(encoding="utf-8") + assert "max_volume: 45" in text + assert "# The MusicMouse config." in text + assert "# where the shelves live" in text + assert "# Volume, 0..100." in text + assert "# no sound from the test suite, please" in text + + # And it still loads. + reloaded = load_config(config_dir / "config.yml") + assert reloaded.general.max_volume == 45 + assert set(reloaded.figures) == {"fuchs", "eule"} + + +async def test_a_new_ceiling_applies_to_the_running_player( + client: httpx2.AsyncClient, sim: Simulation +) -> None: + """A parent lowering the ceiling expects the next song to be quieter, not the next boot.""" + await client.post("/api/volume", json={"percent": 100}) + await sim.bus.drain() + assert sim.player.volume == 60 + + response = await client.put( + "/api/settings", json={**SETTINGS, "max_volume": 30, "initial_volume": 20} + ) + assert response.status_code == 200 + await sim.bus.drain() + + assert sim.player.volume == 30 + assert (await client.get("/api/state")).json()["volume"] == 100 + + +async def test_an_inverted_range_is_rejected(client: httpx2.AsyncClient) -> None: + response = await client.put("/api/settings", json={**SETTINGS, "min_volume": 70}) + assert response.status_code == 422 + assert "min_volume" in response.json()["detail"] + + +async def test_an_initial_volume_outside_the_range_is_rejected( + client: httpx2.AsyncClient, +) -> None: + response = await client.put("/api/settings", json={**SETTINGS, "initial_volume": 90}) + assert response.status_code == 422 + assert "initial_volume" in response.json()["detail"] + + +async def test_out_of_bounds_values_are_rejected_by_the_schema( + client: httpx2.AsyncClient, +) -> None: + assert ( + await client.put("/api/settings", json={**SETTINGS, "button_leds_brightness": 5}) + ).status_code == 422 + assert ( + await client.put("/api/settings", json={**SETTINGS, "volume_increment": 0}) + ).status_code == 422 + + +async def test_a_rejected_save_leaves_the_file_alone( + client: httpx2.AsyncClient, config_dir: Path +) -> None: + before = (config_dir / "config.yml").read_text(encoding="utf-8") + await client.put("/api/settings", json={**SETTINGS, "min_volume": 70}) + assert (config_dir / "config.yml").read_text(encoding="utf-8") == before diff --git a/python-backend/tests/test_startup.py b/python-backend/tests/test_startup.py new file mode 100644 index 0000000..51d1c47 --- /dev/null +++ b/python-backend/tests/test_startup.py @@ -0,0 +1,128 @@ +"""What the app does when the config leaves the hardware out. + +Both of these are deliberately warnings rather than errors: a spare machine running +only the web front-end is a supported way to use this, and it should say so once at +startup rather than fail or - worse - look like it is working when it is not. +""" + +from __future__ import annotations + +import copy +import logging +from pathlib import Path +from typing import Any + +import pytest + +from musicmouse.__main__ import _build_player, wants_hardware +from musicmouse.bus import EventBus +from musicmouse.clock import RealClock +from musicmouse.config import SIMULATE, Config, ConfigError, load_config +from musicmouse.devices.null_transport import NullTransport +from musicmouse.simulator.fake_player import FakePlayer +from tests.conftest import VALID_CONFIG, write_config + + +def _config(config_dir: Path, *, drop: tuple[str, ...] = (), **general: Any) -> Config: + data = copy.deepcopy(VALID_CONFIG) + data["general"].update(general) + for key in drop: + data["general"].pop(key, None) + return load_config(write_config(config_dir, data)) + + +# ------------------------------------------------------------------- serial port + + +def test_a_missing_serial_port_is_an_error(config_dir: Path) -> None: + """Simulation has to be asked for. A config that lost the line must not boot.""" + with pytest.raises(ConfigError) as excinfo: + _config(config_dir, drop=("serial_port",)) + assert "general.serial_port" in str(excinfo.value) + assert "required" in str(excinfo.value) + + +def test_a_missing_alsa_device_is_an_error(config_dir: Path) -> None: + with pytest.raises(ConfigError) as excinfo: + _config(config_dir, drop=("alsa_device",)) + assert "general.alsa_device" in str(excinfo.value) + assert "required" in str(excinfo.value) + + +def test_both_missing_are_reported_together(config_dir: Path) -> None: + with pytest.raises(ConfigError) as excinfo: + _config(config_dir, drop=("serial_port", "alsa_device")) + message = str(excinfo.value) + assert "2 problems" in message + assert "general.serial_port" in message + assert "general.alsa_device" in message + + +def test_simulate_switches_off_the_serial_link( + config_dir: Path, caplog: pytest.LogCaptureFixture +) -> None: + config = _config(config_dir, serial_port=SIMULATE) + assert config.general.serial_simulated is True + + with caplog.at_level(logging.WARNING): + assert wants_hardware(config, False) is False + assert "running without the mouse" in caplog.text + + +def test_a_real_port_is_used(config_dir: Path) -> None: + config = _config(config_dir) + assert config.general.serial_simulated is False + assert wants_hardware(config, False) is True + + +def test_the_flag_wins_over_a_configured_port(config_dir: Path) -> None: + assert wants_hardware(_config(config_dir), True) is False + + +# ------------------------------------------------------------------ audio device + + +async def test_simulate_gives_a_silent_player( + config_dir: Path, caplog: pytest.LogCaptureFixture +) -> None: + bus = EventBus() + await bus.start() + try: + general = _config(config_dir, alsa_device=SIMULATE).general + assert general.audio_simulated is True + + with caplog.at_level(logging.WARNING): + player = _build_player(bus, general, clock=RealClock()) + + assert isinstance(player, FakePlayer) + assert "alsa_device" in caplog.text + assert "nothing will be audible" in caplog.text + # The clamps still come from the config, so volume behaves the same either way. + assert player.volume == 40 + player.set_volume(999) + assert player.volume == 60 + finally: + player.close() + await bus.stop() + + +async def test_an_alsa_device_asks_for_the_real_player(config_dir: Path) -> None: + """Only that it tries: instantiating VlcPlayer needs libVLC, which CI has not got.""" + bus = EventBus() + await bus.start() + try: + general = _config(config_dir, alsa_device="default").general + assert general.audio_simulated is False + with pytest.raises(Exception): # noqa: B017 - ImportError or an OSError from libVLC + _build_player(bus, general, clock=RealClock()) + finally: + await bus.stop() + + +# ---------------------------------------------------------------- null transport + + +def test_the_null_transport_drops_what_it_is_handed() -> None: + transport = NullTransport() + transport.write(b"\x00\x01\x02") + assert transport.connected is False diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py new file mode 100644 index 0000000..6d44fcb --- /dev/null +++ b/python-backend/tests/test_web.py @@ -0,0 +1,357 @@ +"""The web front-end, driven against the simulator. + +Everything below the HTTP layer is production code: the same bus, the same reactions, +the same player interface - only the serial link and VLC are fake. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator, Iterator +from pathlib import Path + +import httpx2 +import pytest +from fastapi import FastAPI + +from musicmouse.config import WebConfig, load_config +from musicmouse.services.web.service import build_app +from musicmouse.simulator.harness import Simulation, build_simulation +from tests.conftest import VALID_CONFIG, write_config +from tests.websocket_harness import websocket_connect + +TRACK_SECONDS = 10.0 + +#: Shorthand: every test takes the same client type. +type Client = httpx2.AsyncClient + + +@pytest.fixture +async def sim(config_dir: Path) -> AsyncIterator[Simulation]: + config = load_config(write_config(config_dir, VALID_CONFIG)) + simulation = await build_simulation(config, track_duration=TRACK_SECONDS) + try: + yield simulation + finally: + await simulation.aclose() + + +@pytest.fixture +def api(sim: Simulation, config_dir: Path) -> Iterator[FastAPI]: + """The real ASGI app, on the test's own event loop.""" + application, hub = build_app(sim.app, WebConfig(), config_dir / "config.yml") + hub.start() + try: + yield application + finally: + hub.stop() + + +@pytest.fixture +async def client(api: FastAPI) -> AsyncIterator[httpx2.AsyncClient]: + transport = httpx2.ASGITransport(app=api) + async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http: + yield http + + +async def album_by_title(client: httpx2.AsyncClient, title: str) -> dict: + albums = (await client.get("/api/library")).json()["albums"] + return next(album for album in albums if album["title"] == title) + + +# ------------------------------------------------------------------------- library + + +async def test_library_lists_every_album_with_its_tracks(client: Client) -> None: + body = (await client.get("/api/library")).json() + titles = {album["title"] for album in body["albums"]} + assert titles == { + "Fuchs", + "Eule", + "Kinderparty Lieder", + "Conni in den Bergen", + "Wissen macht Ah", + } + + album = await album_by_title(client, "Conni in den Bergen") + assert album["kind"] == "book" + assert album["category"] == "Conni" + assert len(album["colors"]) == 3 + assert [track["title"] for track in album["tracks"]] == ["Teil 0", "Teil 1"] + + +async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None: + album = await album_by_title(client, "Fuchs") + assert album["has_cover"] is False + assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404 + + +async def test_unanalyzed_tracks_report_no_analysis(client: Client) -> None: + album = await album_by_title(client, "Eule") + assert all(track["analysis"] is None for track in album["tracks"]) + assert (await client.get(f"/api/tracks/{album['id']}/0/analysis")).status_code == 404 + + +# -------------------------------------------------------------------------- state + + +async def test_state_starts_idle(client: Client) -> None: + state = (await client.get("/api/state")).json() + assert state["playing"] is False + assert state["album_id"] is None + assert state["active_figure"] is None + assert state["connected"] == {"firmware": True, "mqtt": False} + + +async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None: + album = await album_by_title(client, "Kinderparty Lieder") + response = await client.post("/api/play", json={"album_id": album["id"]}) + assert response.status_code == 204 + await sim.bus.drain() + + assert sim.player.is_playing + assert sim.player.playlist is not None + assert sim.player.playlist.album_id == album["id"] + assert (await client.get("/api/state")).json()["album_title"] == "Kinderparty Lieder" + + +async def test_track_titles_come_from_the_tags_not_the_filename( + client: Client, sim: Simulation +) -> None: + """``01 - So ein schoener Tag.mp3`` is a filename, not a title.""" + album = await album_by_title(client, "Fuchs") + await client.post("/api/play", json={"album_id": album["id"]}) + await sim.bus.drain() + + state = (await client.get("/api/state")).json() + assert state["track_title"] == "Track 0" + assert state["duration"] > 0 + + +async def test_play_can_start_at_a_track(client: Client, sim: Simulation) -> None: + album = await album_by_title(client, "Fuchs") + await client.post("/api/play", json={"album_id": album["id"], "track_index": 2}) + await sim.bus.drain() + + assert sim.player.track_index == 2 + assert (await client.get("/api/state")).json()["track_index"] == 2 + + +async def test_playing_a_figure_album_reuses_the_figure_playlist( + client: Client, sim: Simulation +) -> None: + """Identity matters: ``play_figure`` resumes on an ``is`` check.""" + album = await album_by_title(client, "Fuchs") + await client.post("/api/play", json={"album_id": album["id"]}) + await sim.bus.drain() + + assert sim.player.playlist is sim.app.playlists["fuchs"] + + +async def test_playing_an_unknown_album_is_a_404(client: Client) -> None: + response = await client.post("/api/play", json={"album_id": "nope"}) + assert response.status_code == 404 + + +async def test_transport_commands_reach_the_player(client: Client, sim: Simulation) -> None: + album = await album_by_title(client, "Fuchs") + await client.post("/api/play", json={"album_id": album["id"]}) + await sim.bus.drain() + + await client.post("/api/next") + await sim.bus.drain() + assert sim.player.track_index == 1 + + await client.post("/api/previous") + await sim.bus.drain() + assert sim.player.track_index == 0 + + await client.post("/api/pause") + await sim.bus.drain() + assert not sim.player.is_playing + + await client.post("/api/resume") + await sim.bus.drain() + assert sim.player.is_playing + + +async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None: + album = await album_by_title(client, "Fuchs") + await client.post("/api/play", json={"album_id": album["id"]}) + await sim.bus.drain() + + response = await client.post("/api/seek", json={"position": 4.0}) + assert response.status_code == 204 + await sim.bus.drain() + assert sim.player.position == pytest.approx(4.0, abs=0.1) + + +async def test_seeking_backwards_is_rejected(client: Client) -> None: + assert (await client.post("/api/seek", json={"position": -1})).status_code == 422 + + +# ------------------------------------------------------------------------- volume + + +async def test_full_volume_means_the_configured_ceiling( + client: Client, sim: Simulation +) -> None: + """The child sees 0..100; the config's max of 60 never crosses the API boundary.""" + response = await client.post("/api/volume", json={"percent": 100}) + assert response.status_code == 204 + await sim.bus.drain() + + assert sim.player.volume == 60 + assert (await client.get("/api/state")).json()["volume"] == 100 + + +async def test_volume_scales_across_the_allowed_range( + client: Client, sim: Simulation +) -> None: + await client.post("/api/volume", json={"percent": 50}) + await sim.bus.drain() + assert sim.player.volume == 30 + assert (await client.get("/api/state")).json()["volume"] == 50 + + +async def test_volume_steps_are_relative_to_the_percentage( + client: Client, sim: Simulation +) -> None: + await client.post("/api/volume", json={"percent": 50}) + await sim.bus.drain() + await client.post("/api/volume", json={"delta_percent": 10}) + await sim.bus.drain() + + assert (await client.get("/api/state")).json()["volume"] == 60 + assert sim.player.volume == 36 + + +async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -> None: + await client.post("/api/volume", json={"percent": 95}) + await sim.bus.drain() + await client.post("/api/volume", json={"delta_percent": 20}) + await sim.bus.drain() + assert (await client.get("/api/state")).json()["volume"] == 100 + + +async def test_the_device_volume_range_is_never_exposed(client: Client) -> None: + state = (await client.get("/api/state")).json() + assert "volume_min" not in state + assert "volume_max" not in state + + +async def test_volume_needs_one_of_the_two_fields(client: Client) -> None: + assert (await client.post("/api/volume", json={})).status_code == 422 + + +# ---------------------------------------------------------------------- websocket + + +async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -> None: + """State events only fire on change, so a tab that connects mid-track needs this.""" + async with websocket_connect(api, "/api/ws") as socket: + message = await socket.next_json() + assert message["type"] == "state" + assert message["state"]["playing"] is False + + +async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None: + async with websocket_connect(api, "/api/ws") as first, websocket_connect( + api, "/api/ws" + ) as second: + await first.next_json() + await second.next_json() + + await sim.driver.place("fuchs") + await sim.bus.drain() + + for socket in (first, second): + message = await socket.next_json() + assert message["type"] == "state" + assert message["state"]["active_figure"] == "fuchs" + + +async def test_a_refresh_tells_the_clients_to_reload_the_library( + api: FastAPI, client: Client, sim: Simulation +) -> None: + async with websocket_connect(api, "/api/ws") as socket: + await socket.next_json() + + response = await client.post("/api/library/refresh") + assert response.status_code == 202 + + message = await socket.next_json(timeout=5.0) + assert message["type"] == "library" + assert set(sim.app.playlists) == {"fuchs", "eule"} + + +# ------------------------------------------------------------------------ figures + + +async def test_the_web_ui_follows_a_figure_placed_on_the_reader( + client: Client, sim: Simulation +) -> None: + await sim.driver.place("fuchs") + await sim.bus.drain() + + state = (await client.get("/api/state")).json() + assert state["active_figure"] == "fuchs" + assert state["playing"] is True + assert state["album_title"] == "Fuchs" + + +# ------------------------------------------------- through a real uvicorn, not just ASGI + + +async def test_websockets_survive_a_real_server(sim: Simulation, config_dir: Path) -> None: + """Boot the actual service and open an actual websocket. + + Everything above drives the ASGI app directly, which cannot see whether uvicorn is + able to answer an upgrade at all - and it is not, unless a websocket implementation + is installed alongside it. That gap answered ``/api/ws`` with a 404 in production + while every other test passed. + """ + import json + import socket + + import websockets + + from musicmouse.services.web.service import WebService + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = int(probe.getsockname()[1]) + + service = WebService( + sim.app, + WebConfig(host="127.0.0.1", port=port), + config_dir / "config.yml", + ) + server = asyncio.create_task(service.run(), name="web-service") + try: + await _wait_for_port(port) + async with websockets.connect(f"ws://127.0.0.1:{port}/api/ws") as client: + snapshot = json.loads(await asyncio.wait_for(client.recv(), 5.0)) + assert snapshot["type"] == "state" + assert snapshot["state"]["playing"] is False + finally: + server.cancel() + with contextlib.suppress(asyncio.CancelledError): + await server + + +async def _wait_for_port(port: int, timeout: float = 5.0) -> None: + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + except OSError: + await asyncio.sleep(0.05) + continue + del reader + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + return + raise AssertionError(f"nothing listening on {port} after {timeout}s") diff --git a/python-backend/tests/websocket_harness.py b/python-backend/tests/websocket_harness.py new file mode 100644 index 0000000..565dae1 --- /dev/null +++ b/python-backend/tests/websocket_harness.py @@ -0,0 +1,68 @@ +"""Drive an ASGI websocket endpoint on the caller's own event loop. + +Starlette's ``TestClient`` runs the app in a second thread with its own loop, which +would put the bus and the websockets on different loops - so ``bus.emit`` would take +its thread-safe path and a following ``drain()`` could return before the event was even +queued. In the real app there is only ever one loop, and this harness keeps the tests +that way. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import AsyncIterator +from typing import Any + + +class WebSocketSession: + def __init__(self) -> None: + self.to_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self.from_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + async def receive(self) -> dict[str, Any]: + return await self.to_app.get() + + async def send(self, message: dict[str, Any]) -> None: + await self.from_app.put(message) + + async def next_json(self, timeout: float = 2.0) -> dict[str, Any]: + """The next ``websocket.send`` frame, decoded.""" + while True: + message = await asyncio.wait_for(self.from_app.get(), timeout) + if message["type"] == "websocket.send": + text: str = message["text"] + return json.loads(text) + + +@contextlib.asynccontextmanager +async def websocket_connect(app: Any, path: str) -> AsyncIterator[WebSocketSession]: + session = WebSocketSession() + scope = { + "type": "websocket", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "scheme": "ws", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "root_path": "", + "headers": [(b"host", b"testserver")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "subprotocols": [], + "state": {}, + } + await session.to_app.put({"type": "websocket.connect"}) + task = asyncio.create_task(app(scope, session.receive, session.send)) + + accepted = await asyncio.wait_for(session.from_app.get(), 2.0) + assert accepted["type"] == "websocket.accept", accepted + try: + yield session + finally: + await session.to_app.put({"type": "websocket.disconnect", "code": 1000}) + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..4c5632d --- /dev/null +++ b/web/README.md @@ -0,0 +1,120 @@ +# Musik Delphin — the web front-end + +Browse the whole music collection and play any of it from a browser: the six figurines +are only five of the twenty-odd albums on the shelf, and everything else was previously +unreachable without swapping files around on disk. + +Built from `../claude-design/Dolphin Beats.dc.html`, which is a finished interaction and +visual spec rather than a sketch. The keyboard model, the layout, the colours and the +German copy are ported from it; what changed is that a real player sits behind it. + +## Running it + +The backend serves the API. Either of these will do - `--simulate` needs no audio device +and no libVLC at all, which makes it the easier one to develop against: + +```sh +cd ../python-backend +python -m musicmouse --config ./config.yml --no-hardware # real audio +python -m musicmouse --config ./config.yml --simulate # fake player +``` + +Then: + +```sh +npm install +npm run dev # http://localhost:5173, /api and the websocket proxied to :8080 +``` + +For the device, `npm run build` writes `dist/`, and `general.web.static_dir` in +`config.yml` points at it so one process serves both. + +## Checks + +```sh +npm run test # vitest over src/lib +npx tsc --noEmit # strict, noUncheckedIndexedAccess +``` + +Only the pure modules are tested - the search, the keyboard machine and the time +formatting. That is where the behaviour lives; the components are mostly layout. + +## How it is put together + +The split that matters: **what is playing** comes from the backend, **what you are +looking at** lives in the browser. The mockup kept both in one state object, but a real +player has other front-ends - the buttons on the mouse, a figurine on the reader, Home +Assistant - and this UI has to follow them rather than assume it is in charge. So +`isPlaying`, `volume`, the position and the current album all arrive over the websocket, +and only `search / mode / filter / category / selIndex / view / openAlbumId / showHelp` +are local. + +| Path | What it is | +|---|---| +| `src/lib/keyboard.ts` | The key map, as a pure function from a keypress to a list of actions. Testable without a DOM. | +| `src/lib/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant. | +| `src/lib/covers.ts` | Generated cover art from the album's three colours, and the book-spine treatment. | +| `src/hooks/usePlayerState.ts` | The websocket, with reconnect-and-reseed. | +| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. | +| `src/components/` | Layout only. | + +`src/api/types.ts` mirrors `musicmouse/services/web/schemas.py`; the two are hand-kept in +step. + +## Keyboard + +The device is meant to be driven from a keyboard, so every screen is reachable without a +pointer. `F1` shows the same list in-app. + +| Key | | +|---|---| +| `SPACE` | play / pause | +| `CTRL+H` / `CTRL+L` | previous / next track | +| `CTRL+J` / `CTRL+K` | quieter / louder | +| `SHIFT+←` / `SHIFT+→` | seek 15 s | +| `A-Z`, `0-9` | search albums as you type | +| `?` | switch to searching individual tracks | +| `TAB` | cycle all / music / audiobooks | +| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing | +| `ENTER` | play the selection | +| `ESC` | back out one layer at a time | +| `/` | back to browsing | +| `F1` | this list | + +`SHIFT+arrows` is the one addition to the mockup: plain arrows were already taken, and +seeking is the one thing a real player can do that the mockup could not. + +## Parent mode + +`?parentMode=1` opens a settings panel - volume limits, the rotary step, button +brightness, and a button to re-read the library. Saving writes the device's +`config.yml`. + +It is hidden from the child, not protected from them: there is no authentication on the +endpoints behind it. See `../python-backend/README.md`. + +## Installing it on a phone + +It is a PWA: `public/manifest.webmanifest`, icons generated from the mascot, and a small +service worker in `public/sw.js`. On iOS, Share → *Zum Home-Bildschirm*; it then opens +full-screen with no Safari chrome, because iOS reads the `apple-mobile-web-app-*` meta +tags in `index.html` rather than the manifest. + +Two things worth knowing: + +- **The service worker only registers over HTTPS or on `localhost`.** At + `http://:8080` on the LAN, iOS will still install the icon and run it + standalone - that part is the meta tags - but there is no offline caching. Put the + mouse behind a TLS-terminating proxy if you want that. +- **The worker deliberately caches very little**: the app shell, the font and cover art. + Not `/api/state`, not the library, not anything else live. A player that shows stale + state is worse than one that says it cannot reach the mouse. + +Nunito is self-hosted in `public/fonts/` (one variable file per subset, ~74 kB) rather +than pulled from Google. The mouse may have no internet, and a cached shell rendering in +a fallback face looks broken. + +## Not implemented + +The mockup's second page, "Mein Zimmer" (Hue lights, a roller shutter and scenes), is +not built. Nothing here depends on it. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6db22ea --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + Musik Delphin + + + + + + + + + + + + + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..4bac085 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2333 @@ +{ + "name": "musicmouse-web", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "musicmouse-web", + "version": "2.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.0", + "vite": "^7.1.0", + "vitest": "^3.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f2d39f4 --- /dev/null +++ b/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "musicmouse-web", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b", + "test": "vitest run" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.0", + "vite": "^7.1.0", + "vitest": "^3.2.0" + }, + "allowScripts": { + "esbuild@0.28.2": true + } +} diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..64511b1 Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/dolphin-mascot.png b/web/public/dolphin-mascot.png new file mode 100644 index 0000000..bf3fb0c Binary files /dev/null and b/web/public/dolphin-mascot.png differ diff --git a/web/public/fonts/nunito-latin-ext.woff2 b/web/public/fonts/nunito-latin-ext.woff2 new file mode 100644 index 0000000..584a55b Binary files /dev/null and b/web/public/fonts/nunito-latin-ext.woff2 differ diff --git a/web/public/fonts/nunito-latin.woff2 b/web/public/fonts/nunito-latin.woff2 new file mode 100644 index 0000000..e118818 Binary files /dev/null and b/web/public/fonts/nunito-latin.woff2 differ diff --git a/web/public/fonts/nunito.css b/web/public/fonts/nunito.css new file mode 100644 index 0000000..5404723 --- /dev/null +++ b/web/public/fonts/nunito.css @@ -0,0 +1,22 @@ +/* Nunito, self-hosted: one variable file per subset covers 500-900. + + Google Fonts would be a network round trip the mouse may not have, and a + cached app shell rendered in the fallback face looks broken. latin-ext is + what carries "Hörbücher" and "Käpt'n"; the Cyrillic and Vietnamese subsets + Google also offers are dropped, since nothing in this UI can produce them. */ +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 500 900; + font-display: swap; + src: url("/fonts/nunito-latin-ext.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 500 900; + font-display: swap; + src: url("/fonts/nunito-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/web/public/icon-192.png b/web/public/icon-192.png new file mode 100644 index 0000000..a0ef028 Binary files /dev/null and b/web/public/icon-192.png differ diff --git a/web/public/icon-512.png b/web/public/icon-512.png new file mode 100644 index 0000000..1f942e2 Binary files /dev/null and b/web/public/icon-512.png differ diff --git a/web/public/icon-maskable-512.png b/web/public/icon-maskable-512.png new file mode 100644 index 0000000..0b49d90 Binary files /dev/null and b/web/public/icon-maskable-512.png differ diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..5703fa8 --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Musik Delphin", + "short_name": "Musik", + "description": "Musik und Hörbücher für die MusicMouse", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0d2f3c", + "theme_color": "#0d2f3c", + "lang": "de", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { + "src": "/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..b337a76 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,79 @@ +// A deliberately small service worker: it exists so iOS treats this as an installable +// app and so the shell survives a flaky wifi moment. It does NOT try to make the player +// work offline - the audio, the library and the state all live on the mouse, and a +// cached answer for any of those would be a lie. + +const SHELL = "musikdelphin-shell-v1"; +const COVERS = "musikdelphin-covers-v1"; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(SHELL) + .then((cache) => + cache.addAll([ + "/", + "/manifest.webmanifest", + "/dolphin-mascot.png", + "/fonts/nunito.css", + "/fonts/nunito-latin.woff2", + "/fonts/nunito-latin-ext.woff2", + ]), + ) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((names) => + Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + // Cover art is immutable per album id and is the only heavy thing here. + if (url.pathname.startsWith("/api/albums/")) { + event.respondWith( + caches.open(COVERS).then(async (cache) => { + const hit = await cache.match(request); + if (hit) return hit; + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + }), + ); + return; + } + + // Everything else under /api is live state. Never cache it, never serve it stale. + if (url.pathname.startsWith("/api/")) return; + + // The shell: network first so a rebuilt frontend is picked up on the next load, + // falling back to the cache when the mouse is off or out of range. + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(SHELL).then((cache) => cache.put(request, copy)); + } + return response; + }) + .catch(async () => { + const hit = await caches.match(request); + // A client-side app: any in-scope navigation resolves to the one document. + return hit ?? (await caches.match("/")) ?? Response.error(); + }), + ); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..eb20e5c --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,322 @@ +/** Owns the browse state, mounts the keyboard handler, and switches between views. + * + * The split that matters: everything about *what is playing* comes from the backend + * over the websocket, and everything about *what you are looking at* lives here. The + * mockup kept both in one object; a real player has other front-ends - the buttons on + * the mouse, a figure on the reader, Home Assistant - and this UI has to follow them. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { api } from "./api/client"; +import type { Album } from "./api/types"; +import { AlbumModal } from "./components/AlbumModal"; +import { AppHeader } from "./components/AppHeader"; +import { BrowseView } from "./components/BrowseView"; +import { Bubbles } from "./components/Bubbles"; +import { HelpOverlay } from "./components/HelpOverlay"; +import { ParentPanel } from "./components/ParentPanel"; +import { PlayerBar } from "./components/PlayerBar"; +import { PlayView } from "./components/PlayView"; +import { useGridColumns } from "./hooks/useGridColumns"; +import { useLibrary } from "./hooks/useLibrary"; +import { usePlaybackClock } from "./hooks/usePlaybackClock"; +import { usePlayerState } from "./hooks/usePlayerState"; +import type { Action, UiState } from "./lib/keyboard"; +import { handleKey, initialUiState } from "./lib/keyboard"; +import { playPop } from "./lib/pop"; +import type { Filter, Results, SongHit } from "./lib/search"; +import { results as computeResults } from "./lib/search"; + +/** Volume when un-muting, matching the mockup. */ +const UNMUTE_PERCENT = 60; + +export function App() { + const [ui, setUi] = useState(initialUiState); + const library = useLibrary(); + const connection = usePlayerState(library.reload); + const [gridRef, columns] = useGridColumns(); + const [parentMode, setParentMode] = useState( + () => new URLSearchParams(location.search).get("parentMode") === "1", + ); + + const state = connection.state; + const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false); + + useEffect(() => { + setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns })); + }, [columns]); + + const results: Results = useMemo( + () => + computeResults({ + albums: library.albums, + search: ui.search, + mode: ui.mode, + filter: ui.filter, + category: ui.category, + }), + [library.albums, ui.search, ui.mode, ui.filter, ui.category], + ); + + const byId = useMemo( + () => new Map(library.albums.map((album) => [album.id, album])), + [library.albums], + ); + const currentAlbum: Album | null = state?.album_id ? (byId.get(state.album_id) ?? null) : null; + const openAlbum: Album | null = ui.openAlbumId ? (byId.get(ui.openAlbumId) ?? null) : null; + + const play = useCallback((albumId: string, trackIndex: number) => { + playPop(300); + setUi((previous) => ({ ...previous, view: "play", openAlbumId: null })); + void api.play(albumId, trackIndex); + }, []); + + const setVolume = useCallback( + (percent: number) => { + const clamped = Math.max(0, Math.min(100, percent)); + // Optimistic, so the bars move on the keypress rather than a frame later. + connection.optimistic({ volume: clamped }); + void api.setVolume(clamped); + }, + [connection], + ); + + const toggle = useCallback(() => { + if (!state) return; + if (state.album_id === null) { + // Nothing loaded yet: space starts whatever is on screen, as in the mockup. + const first = results.albums[0] ?? library.albums[0]; + if (first) play(first.id, 0); + return; + } + connection.optimistic({ playing: !state.playing }); + void (state.playing ? api.pause() : api.resume()); + }, [connection, library.albums, play, results.albums, state]); + + /** The one place a keyboard action, a click or a tap all end up. */ + const run = useCallback( + (actions: Action[]) => { + for (const action of actions) { + switch (action.type) { + case "ui": + setUi((previous) => ({ ...previous, ...action.patch })); + break; + case "play": + play(action.albumId, action.trackIndex); + break; + case "toggle": + toggle(); + break; + case "next": + playPop(260); + void api.next(); + break; + case "previous": + playPop(260); + void api.previous(); + break; + case "volume": + setVolume((state?.volume ?? 0) + action.delta); + break; + case "seek": + if (state?.duration) { + void api.seek(Math.max(0, Math.min(state.duration, position + action.delta))); + } + break; + case "pop": + playPop(action.freq); + break; + } + } + }, + [play, position, setVolume, state, toggle], + ); + + // Held in a ref so the listener is installed once rather than on every state change. + const latest = useRef({ ui, results, run }); + latest.current = { ui, results, run }; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + // Parent mode has real text fields; typing a title into one must not start a song. + if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; + + const current = latest.current; + const actions = handleKey(event, current.ui, current.results); + if (!actions.length) return; + event.preventDefault(); + current.run(actions); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const onFilter = (filter: Filter) => { + playPop(380); + setUi((previous) => ({ + ...previous, + filter, + selIndex: 0, + view: "browse", + category: null, + })); + }; + + const onCategory = (key: string | null) => { + if (key) playPop(440); + setUi((previous) => ({ ...previous, category: key, selIndex: 0 })); + }; + + const onOpenAlbum = (album: Album, navIndex: number) => { + playPop(420); + setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex })); + }; + + const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index); + const onSeek = (target: number) => void api.seek(target); + const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT); + + return ( +
+ + + {ui.view === "browse" && state && ( +
+ } /> + +
+ )} + + {ui.view === "play" && state && ( + run([{ type: "next" }])} + onPrevious={() => run([{ type: "previous" }])} + onSeek={onSeek} + onVolume={setVolume} + onMute={onMute} + onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))} + /> + )} + + {openAlbum && state && ( + setUi((previous) => ({ ...previous, openAlbumId: null }))} + onPlay={(trackIndex) => play(openAlbum.id, trackIndex)} + /> + )} + + + + {ui.showHelp && ( + setUi((previous) => ({ ...previous, showHelp: false }))} /> + )} + + {ui.view === "browse" && state && ( + run([{ type: "next" }])} + onPrevious={() => run([{ type: "previous" }])} + onSeek={onSeek} + onVolume={setVolume} + onMute={onMute} + onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))} + /> + )} + + {parentMode && setParentMode(false)} />} + + {(!state || library.loading) && } +
+ ); +} + +function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) { + const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null; + if (!label) return null; + return ( +
+ {label} +
+ ); +} + +function Splash({ error }: { error: string | null }) { + return ( +
+ +
+ {error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"} +
+
+ ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..9842672 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,43 @@ +/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */ + +import type { Album, PlayerState, Settings } from "./types"; + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`/api${path}`, { + headers: init?.body ? { "Content-Type": "application/json" } : undefined, + ...init, + }); + if (!response.ok) { + throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`); + } + return response.status === 204 ? (undefined as T) : ((await response.json()) as T); +} + +function post(path: string, body?: unknown): Promise { + return request(path, { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +export const api = { + library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums), + state: () => request("/state"), + refreshLibrary: () => post("/library/refresh"), + + play: (albumId: string, trackIndex = 0) => + post("/play", { album_id: albumId, track_index: trackIndex }), + resume: () => post("/resume"), + pause: () => post("/pause"), + next: () => post("/next"), + previous: () => post("/previous"), + seek: (position: number) => post("/seek", { position }), + setVolume: (percent: number) => post("/volume", { percent }), + nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }), + + settings: () => request("/settings"), + saveSettings: (settings: Settings) => + request("/settings", { method: "PUT", body: JSON.stringify(settings) }), +}; + +export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..71574a1 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,65 @@ +/** The shapes the backend serves. Mirrors `musicmouse/services/web/schemas.py`. */ + +export type AlbumKind = "music" | "book"; + +export interface TrackAnalysis { + tempo: number | null; + energy: number | null; + valence: number | null; + brightness: number | null; + beats: boolean; +} + +export interface Track { + title: string; + /** Seconds, read from the file's tags at scan time. */ + duration: number; + analysis: TrackAnalysis | null; +} + +export interface Album { + id: string; + section: string; + kind: AlbumKind; + title: string; + artist: string; + series: string | null; + figure: string | null; + /** Series for audiobooks, artist for music. What the browse view groups by. */ + category: string; + /** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */ + colors: string[]; + has_cover: boolean; + duration: number; + tracks: Track[]; +} + +export interface PlayerState { + playing: boolean; + album_id: string | null; + album_title: string | null; + artist: string | null; + kind: AlbumKind | null; + track_index: number; + track_title: string | null; + track_count: number; + position: number; + duration: number; + /** Percent, 0..100. The device's configured range never leaves the backend. */ + volume: number; + active_figure: string | null; + connected: { firmware: boolean; mqtt: boolean }; +} + +export interface Settings { + min_volume: number; + max_volume: number; + initial_volume: number; + volume_increment: number; + button_leds_brightness: number; +} + +export type ServerMessage = + | { type: "state"; state: PlayerState } + | { type: "position"; position: number; duration: number } + | { type: "library" }; diff --git a/web/src/components/AlbumModal.tsx b/web/src/components/AlbumModal.tsx new file mode 100644 index 0000000..37910d0 --- /dev/null +++ b/web/src/components/AlbumModal.tsx @@ -0,0 +1,189 @@ +/** The album detail sheet: cover, metadata, and a numbered track list. */ + +import type { Album } from "../api/types"; +import { isBook, unitLabel } from "../lib/covers"; +import { clock } from "../lib/format"; +import { Cover } from "./Cover"; + +interface Props { + album: Album; + currentAlbumId: string | null; + currentTrackIndex: number; + onClose: () => void; + onPlay: (trackIndex: number) => void; +} + +export function AlbumModal({ + album, + currentAlbumId, + currentTrackIndex, + onClose, + onPlay, +}: Props) { + const book = isBook(album); + return ( +
+
event.stopPropagation()} + style={{ + background: "oklch(96% 0.012 210)", + borderRadius: 26, + padding: 26, + width: 640, + maxWidth: "100%", + maxHeight: "100%", + overflow: "auto", + boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)", + }} + > +
+
+ +
+
+
+ {album.title} +
+
+ {album.artist} +
+
+ {book ? "📖 Hörbuch" : "🎵 Musik"} · {unitLabel(album, album.tracks.length)} ·{" "} + {clock(album.duration)} + {album.figure && ` · 🧸 ${album.figure}`} +
+ +
+ +
+ +
+ {album.tracks.map((track, index) => { + const current = currentAlbumId === album.id && currentTrackIndex === index; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/web/src/components/AppHeader.tsx b/web/src/components/AppHeader.tsx new file mode 100644 index 0000000..ddf4915 --- /dev/null +++ b/web/src/components/AppHeader.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; + +interface Props { + /** Shown next to the title when the mouse is reachable but the firmware is not. */ + status?: ReactNode; +} + +export function AppHeader({ status }: Props) { + return ( +
+ +
+ Musik Delphin +
+ {status} +
+ ); +} diff --git a/web/src/components/BrowseView.tsx b/web/src/components/BrowseView.tsx new file mode 100644 index 0000000..c9db555 --- /dev/null +++ b/web/src/components/BrowseView.tsx @@ -0,0 +1,475 @@ +/** The browse screen: filter pills, the search pill, and whichever of the three result + * lists applies. Selection indices run flat across songs, then categories, then albums, + * which is what lets one pair of arrow keys walk the whole page. */ + +import { useEffect, useRef } from "react"; + +import type { Album } from "../api/types"; +import { + albumLine, + aspectOfKind, + cardBackground, + cardShadow, + isBook, + unitLabel, +} from "../lib/covers"; +import { clock } from "../lib/format"; +import type { Filter, Results, SongHit } from "../lib/search"; +import { Cover } from "./Cover"; + +interface Props { + results: Results; + filter: Filter; + mode: "albums" | "tracks"; + search: string; + category: string | null; + selIndex: number; + currentAlbumId: string | null; + gridRef: (element: HTMLElement | null) => void; + onFilter: (filter: Filter) => void; + onCategory: (key: string | null) => void; + onOpenAlbum: (album: Album, navIndex: number) => void; + onPlaySong: (hit: SongHit) => void; +} + +const FILTERS: Array<[Filter, string]> = [ + ["all", "Alles"], + ["music", "🎵 Musik"], + ["book", "📖 Hörbücher"], +]; + +export function BrowseView({ + results, + filter, + mode, + search, + category, + selIndex, + currentAlbumId, + gridRef, + onFilter, + onCategory, + onOpenAlbum, + onPlaySong, +}: Props) { + const scroller = useRef(null); + const { songs, categories, albums, total } = results; + const selected = total ? Math.min(selIndex, total - 1) : -1; + + // Keep the selection on screen as the arrow keys walk past the fold. + useEffect(() => { + const box = scroller.current; + const element = box?.querySelector(`[data-nav-index="${selected}"]`); + if (!box || !element) return; + const top = element.offsetTop - box.offsetTop; + const bottom = top + element.offsetHeight; + const pad = 24; + if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad); + else if (bottom + pad > box.scrollTop + box.clientHeight) { + box.scrollTop = bottom + pad - box.clientHeight; + } + }, [selected]); + + const showSearchBar = search.length > 0 || mode === "tracks"; + const countLabel = + mode === "tracks" + ? `${songs.length} Titel gefunden` + : albums.length + ? `${albums.length} Album${albums.length === 1 ? "" : "en"} gefunden` + : "nichts gefunden"; + + const sectionLabel = + filter === "book" ? "Hörbücher" : filter === "music" ? "Alben" : "Alben & Hörbücher"; + const categoryLabel = + filter === "book" ? "Figuren" : filter === "music" ? "Künstler" : "Figuren & Künstler"; + + return ( + <> +
+ {FILTERS.map(([value, label]) => ( + + ))} +
+ + {showSearchBar && ( +
+
+ + {mode === "tracks" ? "♪ Titel" : "🔎 Alben"} + + {search ? `"${search}"` : "tippe …"} +
+
+ {countLabel} +
+
+ )} + +
+ {songs.length > 0 && ( +
+ + Titel {songs.length} Treffer + +
+ {songs.map((hit, index) => ( + + ))} +
+
+ )} + + {categories.length > 0 && ( +
+ {categoryLabel} +
+ {categories.map((entry, index) => { + const navIndex = songs.length + index; + const books = entry.albums.filter(isBook).length; + const allBooks = books === entry.albums.length; + const mostlyBooks = books * 2 > entry.albums.length; + const shown = entry.albums.slice(0, 4); + return ( + + ); + })} +
+
+ )} + + {albums.length > 0 && ( +
+ {category !== null && !search && ( +
+ +
+ {category} +
+
+ )} + {search.length > 0 && {sectionLabel}} +
+ {albums.map((album, index) => { + const navIndex = songs.length + categories.length + index; + return ( + + ); + })} +
+
+ )} + + {total === 0 && ( +
+ +
+ Nichts gefunden — probier andere Buchstaben! +
+
+ )} +
+ + ); +} + +function SectionTitle({ + children, + centered, +}: { + children: React.ReactNode; + centered?: boolean; +}) { + return ( +
+ {children} +
+ ); +} + +function Muted({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/src/components/Bubbles.tsx b/web/src/components/Bubbles.tsx new file mode 100644 index 0000000..dff5250 --- /dev/null +++ b/web/src/components/Bubbles.tsx @@ -0,0 +1,32 @@ +/** The ambient bubble field. Positions are fixed so they do not reshuffle on render. */ + +const BUBBLES: Array<[left: number, size: number, alpha: number, seconds: number, delay: number]> = + [ + [5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4], + [58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5], + [2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2], + [38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2], + [66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2], + [96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6], + ]; + +export function Bubbles() { + return ( + <> + {BUBBLES.map(([left, size, alpha, seconds, delay], index) => ( +
+ ))} + + ); +} diff --git a/web/src/components/Cover.tsx b/web/src/components/Cover.tsx new file mode 100644 index 0000000..0b4ed4e --- /dev/null +++ b/web/src/components/Cover.tsx @@ -0,0 +1,90 @@ +/** Album art, or a generated stand-in built from the album's own three colours. + * + * The gradient sits underneath the image rather than instead of it, so a cover that is + * still loading - or an album that has none - never shows a hole. + * + * The shape comes from the media type and from nowhere else: square for an album, + * taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio. + */ + +import { coverUrl } from "../api/client"; +import type { Album } from "../api/types"; +import { aspectOf, coverBackground, isBook } from "../lib/covers"; + +interface Props { + album: Album; + /** Rendered size in px along the fixed axis; drives the stripe width so the generated + * pattern scales with the art. */ + size: number; + /** Which axis the parent constrains. `height` keeps rows and grids even when a book + * and an album sit side by side. */ + fit?: "width" | "height"; + radius?: string; + className?: string; + style?: React.CSSProperties; + /** Print the title over generated art. Off for thumbnails, where it would not fit. */ + label?: boolean; +} + +export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) { + const book = isBook(album); + const defaultRadius = book ? "6px 18px 18px 6px" : "16px"; + const box: React.CSSProperties = + fit === "height" + ? { height: "100%", width: "auto", aspectRatio: aspectOf(album) } + : { width: "100%", aspectRatio: aspectOf(album) }; + + return ( +
+ {!album.has_cover && label && ( +
+ {album.title} +
+ )} + {album.has_cover && ( + + )} +
+ ); +} diff --git a/web/src/components/HelpOverlay.tsx b/web/src/components/HelpOverlay.tsx new file mode 100644 index 0000000..e683dcb --- /dev/null +++ b/web/src/components/HelpOverlay.tsx @@ -0,0 +1,63 @@ +/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there, + * it changes here. */ + +const KEYS: Array<[caps: string[], label: string]> = [ + [["LEER"], "Play / Pause"], + [["CTRL+H", "CTRL+L"], "Song zurück / vor"], + [["CTRL+J", "CTRL+K"], "Leiser / lauter"], + [["⇧←", "⇧→"], "15 Sekunden zurück / vor"], + [["A-Z"], "Album oder Hörbuch suchen"], + [["?"], "Einzelne Titel suchen"], + [["F1"], "Diese Hilfe"], + [["TAB"], "Musik / Hörbücher / alles"], + [["← ↑ ↓ →"], "Auswahl bewegen"], + [["ENTER"], "Auswahl abspielen"], + [["ESC"], "Schließen / Suche löschen"], +]; + +export function HelpOverlay({ onClose }: { onClose: () => void }) { + return ( +
+
+
+ ⌨️ Zaubertasten +
+
+ {KEYS.map(([caps, label]) => ( +
+
+ {caps.map((cap) => ( +
1 ? 0 : 56 }}> + {cap} +
+ ))} +
+
+ {label} +
+
+ ))} +
+
+ Tippe irgendwohin, um zu schließen +
+
+
+ ); +} diff --git a/web/src/components/ParentPanel.tsx b/web/src/components/ParentPanel.tsx new file mode 100644 index 0000000..bf402d9 --- /dev/null +++ b/web/src/components/ParentPanel.tsx @@ -0,0 +1,167 @@ +/** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI. + * + * This hides the settings, it does not protect them - there is no login, and the + * endpoints behind it are as open as the rest of the API. That matches the device's + * threat model (a box on a home network) and is stated in the README rather than + * implied. + */ + +import { useEffect, useState } from "react"; + +import { api } from "../api/client"; +import type { Settings } from "../api/types"; + +const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [ + ["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1], + ["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1], + ["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1], + ["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1], + ["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05], +]; + +export function ParentPanel({ onClose }: { onClose: () => void }) { + const [settings, setSettings] = useState(null); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api + .settings() + .then(setSettings) + .catch((cause: unknown) => setStatus(String(cause))); + }, []); + + const save = async () => { + if (!settings) return; + setBusy(true); + setStatus(null); + try { + setSettings(await api.saveSettings(settings)); + setStatus("Gespeichert."); + } catch { + // The backend rejects an inverted range or an out-of-band start volume. + setStatus("Nicht gespeichert — bitte die Werte prüfen (min ≤ Start ≤ max)."); + } finally { + setBusy(false); + } + }; + + const rescan = async () => { + setStatus("Bibliothek wird eingelesen …"); + await api.refreshLibrary(); + setStatus("Einlesen gestartet. Neue Titel erscheinen gleich von selbst."); + }; + + return ( +
+
+
+
+ 🔧 Eltern-Einstellungen +
+ +
+ + {settings === null ? ( +
Wird geladen …
+ ) : ( +
+ {FIELDS.map(([key, label, hint, min, max, step]) => ( + + ))} + +
+ + +
+ +
+ Speichern schreibt in die config.yml auf dem Gerät. Die neue + Obergrenze gilt sofort, ohne Neustart. +
+
+ )} + + {status && ( +
+ {status} +
+ )} +
+
+ ); +} diff --git a/web/src/components/PlayView.tsx b/web/src/components/PlayView.tsx new file mode 100644 index 0000000..9c1da55 --- /dev/null +++ b/web/src/components/PlayView.tsx @@ -0,0 +1,230 @@ +/** The full-screen now-playing view. */ + +import type { Album, PlayerState } from "../api/types"; +import { albumLine, isBook } from "../lib/covers"; +import { clock, remainingInAlbum } from "../lib/format"; +import { Cover } from "./Cover"; +import { ProgressBar } from "./ProgressBar"; +import { Transport } from "./Transport"; +import { VolumeBars } from "./VolumeBars"; + +interface Props { + state: PlayerState; + album: Album | null; + position: number; + onToggle: () => void; + onNext: () => void; + onPrevious: () => void; + onSeek: (position: number) => void; + onVolume: (percent: number) => void; + onMute: () => void; + onBrowse: () => void; +} + +export function PlayView({ + state, + album, + position, + onToggle, + onNext, + onPrevious, + onSeek, + onVolume, + onMute, + onBrowse, +}: Props) { + const book = album ? isBook(album) : false; + const remaining = album + ? remainingInAlbum( + album.tracks.map((track) => track.duration), + state.track_index, + position, + ) + : 0; + + return ( +
+
+ + +
+ {album ? ( + + ) : ( +
+ )} +
+ +
+
+ {state.track_title ?? "Wähl ein Album!"} +
+
+ {album ? albumLine(album) : "Tippen oder klicken"} +
+ {album && ( +
+ {book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count} +
+ )} +
+ +
+ +
+ + {state.duration ? `−${clock(state.duration - position)}` : ""} + + + {album ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""} + +
+
+ +
+ +
+ +
+ +
+ + +
+
+ ); +} diff --git a/web/src/components/PlayerBar.tsx b/web/src/components/PlayerBar.tsx new file mode 100644 index 0000000..afc263f --- /dev/null +++ b/web/src/components/PlayerBar.tsx @@ -0,0 +1,158 @@ +/** The bar along the bottom of the browse screen. */ + +import type { Album, PlayerState } from "../api/types"; +import { albumLine, isBook } from "../lib/covers"; +import { Cover } from "./Cover"; +import { ProgressBar } from "./ProgressBar"; +import { Transport } from "./Transport"; +import { VolumeBars } from "./VolumeBars"; + +interface Props { + state: PlayerState; + album: Album | null; + position: number; + onToggle: () => void; + onNext: () => void; + onPrevious: () => void; + onSeek: (position: number) => void; + onVolume: (percent: number) => void; + onMute: () => void; + onOpenPlayView: () => void; +} + +export function PlayerBar({ + state, + album, + position, + onToggle, + onNext, + onPrevious, + onSeek, + onVolume, + onMute, + onOpenPlayView, +}: Props) { + return ( +
+ + + + +
+
+ {state.track_title ?? "Wähl ein Album!"} +
+
+ {album ? albumLine(album) : "Tippen oder klicken"} +
+ {album && ( +
+ {isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "} + {state.track_count} +
+ )} +
+ +
+ +
+ + + + +
+ ); +} diff --git a/web/src/components/ProgressBar.tsx b/web/src/components/ProgressBar.tsx new file mode 100644 index 0000000..0efaaa0 --- /dev/null +++ b/web/src/components/ProgressBar.tsx @@ -0,0 +1,70 @@ +/** The progress bar, and the one thing the mockup could not do: scrubbing. */ + +import { useCallback, useRef, useState } from "react"; + +interface Props { + position: number; + duration: number; + onSeek: (position: number) => void; + height: number; + interactive?: boolean; +} + +export function ProgressBar({ position, duration, onSeek, height, interactive }: Props) { + const track = useRef(null); + const [dragging, setDragging] = useState(null); + + const positionAt = useCallback( + (clientX: number): number => { + const box = track.current?.getBoundingClientRect(); + if (!box || !duration) return 0; + const ratio = Math.min(1, Math.max(0, (clientX - box.left) / box.width)); + return ratio * duration; + }, + [duration], + ); + + const shown = dragging ?? position; + const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0; + + const onPointerDown = (event: React.PointerEvent) => { + if (!interactive || !duration) return; + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(positionAt(event.clientX)); + }; + + return ( +
{ + if (dragging !== null) setDragging(positionAt(event.clientX)); + }} + onPointerUp={(event) => { + if (dragging === null) return; + const target = positionAt(event.clientX); + setDragging(null); + onSeek(target); + }} + style={{ + height, + borderRadius: 999, + background: "oklch(30% 0.03 210 / .6)", + overflow: "hidden", + cursor: interactive && duration ? "pointer" : "default", + touchAction: "none", + }} + > +
+
+ ); +} diff --git a/web/src/components/Transport.tsx b/web/src/components/Transport.tsx new file mode 100644 index 0000000..912d28e --- /dev/null +++ b/web/src/components/Transport.tsx @@ -0,0 +1,94 @@ +/** The three transport buttons, at two sizes. The glyphs are CSS shapes, as in the + * mockup - no icon font to load on a device that boots offline. */ + +interface Props { + playing: boolean; + onToggle: () => void; + onNext: () => void; + onPrevious: () => void; + /** Side length of the skip buttons; the play button scales from it. */ + size: number; + gap: number; +} + +export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }: Props) { + const glyph = Math.round(size * 0.33); + const bar = Math.max(4, Math.round(size * 0.085)); + const playSize = Math.round(size * 1.26); + const playBar = Math.max(6, Math.round(playSize * 0.1)); + const playGlyph = Math.round(playSize * 0.37); + + return ( +
+ + + + + +
+ ); +} diff --git a/web/src/components/VolumeBars.tsx b/web/src/components/VolumeBars.tsx new file mode 100644 index 0000000..6b4830f --- /dev/null +++ b/web/src/components/VolumeBars.tsx @@ -0,0 +1,62 @@ +/** Five bars, not a slider. The device's real range never reaches the browser: 100 % + * here means whatever ceiling a parent configured. */ + +interface Props { + /** 0..100. */ + volume: number; + onChange: (percent: number) => void; + onMute: () => void; + barWidth: number; + barHeight: string; + gap: number; + iconSize: number; +} + +export function VolumeBars({ + volume, + onChange, + onMute, + barWidth, + barHeight, + gap, + iconSize, +}: Props) { + return ( +
+ +
+ {[20, 40, 60, 80, 100].map((level) => ( +
+
+ ); +} diff --git a/web/src/hooks/useGridColumns.ts b/web/src/hooks/useGridColumns.ts new file mode 100644 index 0000000..36e7c16 --- /dev/null +++ b/web/src/hooks/useGridColumns.ts @@ -0,0 +1,40 @@ +/** How many columns the album grid actually has. + +Arrow-key navigation has to move a whole row at a time, and the grid is +`repeat(auto-fill, minmax(180px, 1fr))` - so the count is whatever the browser worked +out, not something the code can assume. +*/ + +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useGridColumns(): [(element: HTMLElement | null) => void, number] { + const [columns, setColumns] = useState(4); + const element = useRef(null); + + const measure = useCallback(() => { + const grid = element.current; + if (!grid) return; + const count = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length; + if (count) setColumns((previous) => (count === previous ? previous : count)); + }, []); + + const ref = useCallback( + (node: HTMLElement | null) => { + element.current = node; + if (node) requestAnimationFrame(measure); + }, + [measure], + ); + + useEffect(() => { + const observer = new ResizeObserver(measure); + if (element.current) observer.observe(element.current); + window.addEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [measure]); + + return [ref, columns]; +} diff --git a/web/src/hooks/useLibrary.ts b/web/src/hooks/useLibrary.ts new file mode 100644 index 0000000..4f1b749 --- /dev/null +++ b/web/src/hooks/useLibrary.ts @@ -0,0 +1,38 @@ +/** The whole album index, fetched once. + +At roughly 90 kB for a real collection this is cheaper than a search endpoint and makes +type-to-search instant, which is what the keyboard-first design needs. +*/ + +import { useCallback, useEffect, useState } from "react"; + +import { api } from "../api/client"; +import type { Album } from "../api/types"; + +export interface Library { + albums: Album[]; + loading: boolean; + error: string | null; + reload: () => void; +} + +export function useLibrary(): Library { + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(() => { + api + .library() + .then((loaded) => { + setAlbums(loaded); + setError(null); + }) + .catch((cause: unknown) => setError(String(cause))) + .finally(() => setLoading(false)); + }, []); + + useEffect(reload, [reload]); + + return { albums, loading, error, reload }; +} diff --git a/web/src/hooks/usePlaybackClock.ts b/web/src/hooks/usePlaybackClock.ts new file mode 100644 index 0000000..38c0ee1 --- /dev/null +++ b/web/src/hooks/usePlaybackClock.ts @@ -0,0 +1,33 @@ +/** A smooth playback position, from a position that only arrives twice a second. + +Driving a progress bar straight off the websocket visibly steps. This interpolates +between frames on every animation frame and re-seeds whenever a real one lands, which +also gives beat-synced animation the frame-accurate clock it will need later. +*/ + +import { useEffect, useRef, useState } from "react"; + +export function usePlaybackClock(position: number, playing: boolean): number { + const [interpolated, setInterpolated] = useState(position); + const anchor = useRef({ position, at: performance.now() }); + + // A new server frame is the truth; restart the interpolation from it. + useEffect(() => { + anchor.current = { position, at: performance.now() }; + setInterpolated(position); + }, [position]); + + useEffect(() => { + if (!playing) return; + let frame = 0; + const tick = () => { + const { position: base, at } = anchor.current; + setInterpolated(base + (performance.now() - at) / 1000); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [playing]); + + return playing ? interpolated : position; +} diff --git a/web/src/hooks/usePlayerState.ts b/web/src/hooks/usePlayerState.ts new file mode 100644 index 0000000..e60533a --- /dev/null +++ b/web/src/hooks/usePlayerState.ts @@ -0,0 +1,82 @@ +/** The live connection to the backend. + +State arrives over a websocket that only pushes: every command goes out as a plain +POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is +still right, and `onLibraryChanged` fires when a rescan has finished. +*/ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { api } from "../api/client"; +import type { PlayerState, ServerMessage } from "../api/types"; + +const RECONNECT_DELAY_MS = 1500; + +export interface Connection { + state: PlayerState | null; + online: boolean; + /** Apply a change locally so a keypress feels instant; the next frame reconciles. */ + optimistic: (patch: Partial) => void; +} + +export function usePlayerState(onLibraryChanged: () => void): Connection { + const [state, setState] = useState(null); + const [online, setOnline] = useState(false); + + // Kept in a ref so reconnecting never re-runs the effect that owns the socket. + const libraryChanged = useRef(onLibraryChanged); + libraryChanged.current = onLibraryChanged; + + useEffect(() => { + let socket: WebSocket | null = null; + let retry: ReturnType | undefined; + let closed = false; + + const connect = () => { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + socket = new WebSocket(`${protocol}//${location.host}/api/ws`); + + socket.onopen = () => { + setOnline(true); + // The server sends a snapshot on connect, but a reconnect may have missed + // changes in between, so ask for the truth as well. + void api.state().then(setState).catch(() => undefined); + }; + + socket.onmessage = (event) => { + const message = JSON.parse(event.data as string) as ServerMessage; + if (message.type === "state") { + setState(message.state); + } else if (message.type === "position") { + setState((previous) => + previous + ? { ...previous, position: message.position, duration: message.duration } + : previous, + ); + } else if (message.type === "library") { + libraryChanged.current(); + } + }; + + const reconnect = () => { + setOnline(false); + if (!closed) retry = setTimeout(connect, RECONNECT_DELAY_MS); + }; + socket.onclose = reconnect; + socket.onerror = () => socket?.close(); + }; + + connect(); + return () => { + closed = true; + clearTimeout(retry); + socket?.close(); + }; + }, []); + + const optimistic = useCallback((patch: Partial) => { + setState((previous) => (previous ? { ...previous, ...patch } : previous)); + }, []); + + return { state, online, optimistic }; +} diff --git a/web/src/lib/__tests__/format.test.ts b/web/src/lib/__tests__/format.test.ts new file mode 100644 index 0000000..ab031c9 --- /dev/null +++ b/web/src/lib/__tests__/format.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { clock, remainingInAlbum } from "../format"; + +describe("clock", () => { + it("formats minutes and seconds", () => { + expect(clock(0)).toBe("0:00"); + expect(clock(9)).toBe("0:09"); + expect(clock(200.5)).toBe("3:21"); + }); + + it("grows an hours field for a long audiobook", () => { + expect(clock(3661)).toBe("1:01:01"); + }); + + it("never shows negative time", () => { + expect(clock(-5)).toBe("0:00"); + }); +}); + +describe("remainingInAlbum", () => { + it("counts the rest of this track plus every track after it", () => { + expect(remainingInAlbum([60, 30, 10], 0, 20)).toBe(80); + expect(remainingInAlbum([60, 30, 10], 2, 0)).toBe(10); + }); + + it("copes with a position past the end of the track", () => { + expect(remainingInAlbum([60, 30], 0, 999)).toBe(30); + }); + + it("returns zero for an empty album", () => { + expect(remainingInAlbum([], 0, 0)).toBe(0); + }); +}); diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts new file mode 100644 index 0000000..1a16960 --- /dev/null +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import type { Album } from "../../api/types"; +import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard"; +import { normalize, results as computeResults } from "../search"; + +function album(id: string, over: Partial = {}): Album { + return { + id, + section: "Musik", + kind: "music", + title: `Album ${id}`, + artist: "Kinderparty", + series: null, + figure: null, + category: "Kinderparty", + colors: ["#111111", "#222222", "#333333"], + has_cover: false, + duration: 120, + tracks: [ + { title: `Lied ${id}`, duration: 60, analysis: null }, + { title: "Zweites Lied", duration: 60, analysis: null }, + ], + ...over, + }; +} + +const ALBUMS: Album[] = [ + album("a"), + album("b", { title: "Hörbuch Conni", artist: "Conni", kind: "book", series: "Conni", category: "Conni" }), + album("c", { artist: "Rolf", category: "Rolf" }), +]; + +const key = (k: string, over: Partial[0]> = {}) => ({ + key: k, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...over, +}); + +const resultsFor = (ui: UiState) => + computeResults({ + albums: ALBUMS, + search: ui.search, + mode: ui.mode, + filter: ui.filter, + category: ui.category, + }); + +const press = (k: string, ui: UiState = initialUiState, over = {}) => + handleKey(key(k, over), ui, resultsFor(ui)); + +describe("normalize", () => { + it("strips case, punctuation and diacritics so a child's letters match", () => { + expect(normalize("Hörbücher, Folge 2!")).toBe("horbucherfolge2"); + expect(normalize("Käpt'n Krabbe")).toBe("kaptnkrabbe"); + }); +}); + +describe("search", () => { + it("shows categories with no query, albums once there is one", () => { + expect(resultsFor(initialUiState).categories).toHaveLength(3); + expect(resultsFor(initialUiState).albums).toHaveLength(0); + + const searching = { ...initialUiState, search: "conni" }; + expect(resultsFor(searching).albums.map((a) => a.id)).toEqual(["b"]); + }); + + it("filters books and music apart", () => { + const books = resultsFor({ ...initialUiState, filter: "book", search: "a" }); + expect(books.albums.every((a) => a.kind === "book")).toBe(true); + }); + + it("matches title and artist together", () => { + expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]); + }); +}); + +describe("keyboard", () => { + it("types letters into the search", () => { + expect(press("k")).toEqual([ + { type: "ui", patch: { search: "k", view: "browse", selIndex: 0 } }, + ]); + }); + + it("accepts umlauts, which the mockup's a-z0-9 test rejected", () => { + expect(press("ö")).toHaveLength(1); + }); + + it("ignores keys that mean nothing, so the browser keeps its own", () => { + expect(press("F5")).toEqual([]); + expect(press("r", { ...initialUiState }, { ctrlKey: true })).toEqual([]); + }); + + it("maps the ctrl chords to transport and volume", () => { + expect(press("l", initialUiState, { ctrlKey: true })).toEqual([{ type: "next" }]); + expect(press("h", initialUiState, { ctrlKey: true })).toEqual([{ type: "previous" }]); + expect(press("k", initialUiState, { ctrlKey: true })).toEqual([ + { type: "volume", delta: 10 }, + ]); + expect(press("j", initialUiState, { ctrlKey: true })).toEqual([ + { type: "volume", delta: -10 }, + ]); + }); + + it("cycles the filter with TAB", () => { + expect(press("Tab")).toContainEqual({ + type: "ui", + patch: { filter: "music", selIndex: 0, view: "browse", category: null }, + }); + expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({ + type: "ui", + patch: { filter: "all", selIndex: 0, view: "browse", category: null }, + }); + }); + + it("gives arrows to navigation while browsing and to transport while playing", () => { + expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]); + + const playing: UiState = { ...initialUiState, view: "play" }; + expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]); + expect(press("ArrowUp", playing)).toEqual([{ type: "volume", delta: 10 }]); + expect(press("ArrowDown", playing)).toEqual([{ type: "volume", delta: -10 }]); + }); + + it("seeks with shift+arrows in either view", () => { + expect(press("ArrowRight", initialUiState, { shiftKey: true })).toEqual([ + { type: "seek", delta: 15 }, + ]); + expect(press("ArrowLeft", { ...initialUiState, view: "play" }, { shiftKey: true })).toEqual([ + { type: "seek", delta: -15 }, + ]); + }); + + it("moves a whole row at a time in the grid", () => { + const ui = { ...initialUiState, cols: 2, selIndex: 0 }; + expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([ + { type: "ui", patch: { selIndex: 2 } }, + ]); + }); + + it("clamps the selection to what is on screen", () => { + const ui = { ...initialUiState, selIndex: 2 }; + expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([ + { type: "ui", patch: { selIndex: 2 } }, + ]); + }); + + it("peels one layer at a time with ESC", () => { + const deep: UiState = { + ...initialUiState, + showHelp: true, + openAlbumId: "a", + search: "x", + mode: "tracks", + category: "Conni", + }; + expect(press("Escape", deep)).toEqual([ + { type: "ui", patch: { showHelp: false, openAlbumId: null } }, + ]); + + const searching = { ...deep, showHelp: false, openAlbumId: null }; + expect(press("Escape", searching)).toEqual([ + { type: "ui", patch: { search: "", selIndex: 0 } }, + ]); + + const inTracks = { ...searching, search: "" }; + expect(press("Escape", inTracks)).toEqual([ + { type: "ui", patch: { mode: "albums", selIndex: 0 } }, + ]); + + const inCategory = { ...inTracks, mode: "albums" as const }; + expect(press("Escape", inCategory)).toEqual([ + { type: "ui", patch: { category: null, selIndex: 0 } }, + ]); + }); + + it("does not swallow backspace when there is nothing to delete", () => { + expect(press("Backspace")).toEqual([]); + expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([ + { type: "ui", patch: { search: "a", selIndex: 0 } }, + ]); + }); + + it("switches to track search with ?", () => { + expect(press("?")).toContainEqual({ + type: "ui", + patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false }, + }); + }); + + it("plays the open album from the top on ENTER", () => { + expect(press("Enter", { ...initialUiState, openAlbumId: "b" })).toEqual([ + { type: "play", albumId: "b", trackIndex: 0 }, + ]); + }); +}); + +describe("selectionAt", () => { + it("walks songs, then categories, then albums in one flat index space", () => { + const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" }; + const found = resultsFor(ui); + expect(found.songs.length).toBeGreaterThan(0); + expect(selectionAt(found, 0)).toEqual({ + type: "play", + albumId: found.songs[0]!.album.id, + trackIndex: found.songs[0]!.index, + }); + }); + + it("opens a category rather than playing it", () => { + const found = resultsFor(initialUiState); + expect(selectionAt(found, 0)).toEqual({ + type: "ui", + patch: { category: found.categories[0]!.key, selIndex: 0 }, + }); + }); + + it("returns nothing when there is nothing to select", () => { + const ui: UiState = { ...initialUiState, search: "zzzznothing" }; + expect(selectionAt(resultsFor(ui), 0)).toBeNull(); + }); +}); diff --git a/web/src/lib/covers.ts b/web/src/lib/covers.ts new file mode 100644 index 0000000..265d22f --- /dev/null +++ b/web/src/lib/covers.ts @@ -0,0 +1,77 @@ +/** How an album is painted when there is no artwork, and how a book is made to look + * like a book. + * + * The mockup generated everything from a single per-album `hue`. The backend now sends + * three colours pulled out of the real cover art, so the generated fallback and the + * artwork agree - and so do the LED strips, which run the same primary colour. + */ + +import type { Album, AlbumKind } from "../api/types"; + +export const isBook = (album: Album): boolean => album.kind === "book"; + +/** Shape is how you tell the two apart without reading anything: albums are square, + * audiobooks are taller than wide, everywhere they appear - grid, list, group preview, + * player bar, now-playing. Nothing else may set an aspect ratio on a cover. */ +export const ALBUM_ASPECT = 1; +export const BOOK_ASPECT = 0.82; + +export const aspectOfKind = (kind: AlbumKind): number => + kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT; + +export const aspectOf = (album: Album): number => aspectOfKind(album.kind); + +const colours = (album: Album): [string, string, string] => [ + album.colors[0] ?? "#4a6fa5", + album.colors[1] ?? "#6a8fc5", + album.colors[2] ?? "#a5804a", +]; + +/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */ +export function stripes(album: Album, width: number): string { + const [primary, secondary] = colours(album); + return ( + `repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` + + `${secondary} ${width}px, ${secondary} ${width * 2}px)` + ); +} + +/** A book spine: page edges on the right, a darker board on the left. */ +export function spine(album: Album): string { + const [primary, secondary] = colours(album); + return [ + "linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%," + + " oklch(90% 0.03 88) 97.5% 100%)", + "linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," + + " transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)", + `linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`, + `linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`, + ].join(","); +} + +export function coverBackground(album: Album, stripeWidth: number): string { + return isBook(album) ? spine(album) : stripes(album, stripeWidth); +} + +/** The card's own tint: warm paper for books, cool glass for music. */ +export const cardBackground = (album: Album): string => + isBook(album) ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"; + +export const cardShadow = (album: Album): string => + isBook(album) + ? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7)," + + " 0 6px 18px oklch(15% 0.05 210 / .35)" + : "0 6px 18px oklch(15% 0.05 210 / .35)"; + +export const unitLabel = (album: Album, count: number): string => + isBook(album) + ? `${count} ${count === 1 ? "Kapitel" : "Kapitel"}` + : `${count} ${count === 1 ? "Song" : "Songs"}`; + +/** "Album · Artist", unless a podcast makes those the same string. */ +export const albumLine = (album: Album): string => { + const prefix = isBook(album) ? "📖 " : ""; + return album.artist && album.artist !== album.title + ? `${prefix}${album.title} · ${album.artist}` + : `${prefix}${album.title}`; +}; diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..268ac14 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,25 @@ +/** Time formatting, ported from the design mockup. */ + +/** Seconds as `m:ss`, or `h:mm:ss` once an audiobook runs past the hour. */ +export function clock(totalSeconds: number): string { + const total = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`; +} + +/** How much of the album is left after the current position. */ +export function remainingInAlbum( + trackDurations: number[], + trackIndex: number, + position: number, +): number { + const current = trackDurations[trackIndex] ?? 0; + let rest = Math.max(0, current - position); + for (let i = trackIndex + 1; i < trackDurations.length; i++) { + rest += trackDurations[i] ?? 0; + } + return rest; +} diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts new file mode 100644 index 0000000..6234c1e --- /dev/null +++ b/web/src/lib/keyboard.ts @@ -0,0 +1,187 @@ +/** The keyboard state machine, ported from the design mockup. + +Kept pure - it takes a key and the current view state and returns what should happen - +so the whole interaction model can be tested without a DOM, and so the component that +mounts it stays a thin adapter. + +The mockup's key map is followed exactly, with one addition: SHIFT+arrows seek. Plain +arrows were already taken (selection while browsing, transport and volume while +playing), and seeking is the one thing a real player can do that the mockup could not. +*/ + +import type { Album } from "../api/types"; +import type { Filter, Mode, Results } from "./search"; + +export interface UiState { + search: string; + mode: Mode; + filter: Filter; + category: string | null; + selIndex: number; + view: "browse" | "play"; + openAlbumId: string | null; + showHelp: boolean; + cols: number; +} + +export const initialUiState: UiState = { + search: "", + mode: "albums", + filter: "all", + category: null, + selIndex: 0, + view: "browse", + openAlbumId: null, + showHelp: false, + cols: 4, +}; + +export type Action = + | { type: "ui"; patch: Partial } + | { type: "play"; albumId: string; trackIndex: number } + | { type: "toggle" } + | { type: "next" } + | { type: "previous" } + | { type: "volume"; delta: number } + | { type: "seek"; delta: number } + | { type: "pop"; freq: number }; + +/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */ +const SEARCHABLE = /^[\p{L}\p{N}]$/u; + +const FILTER_ORDER: Filter[] = ["all", "music", "book"]; + +export const VOLUME_STEP = 10; +export const SEEK_STEP = 15; + +/** What the flat selection index currently points at. */ +export function selectionAt(results: Results, selIndex: number): Action | null { + const { songs, categories, albums } = results; + const index = Math.min(selIndex, results.total - 1); + if (index < 0) return null; + + if (index < songs.length) { + const hit = songs[index]; + return hit ? { type: "play", albumId: hit.album.id, trackIndex: hit.index } : null; + } + const afterSongs = index - songs.length; + if (afterSongs < categories.length) { + const category = categories[afterSongs]; + return category ? { type: "ui", patch: { category: category.key, selIndex: 0 } } : null; + } + const album: Album | undefined = albums[afterSongs - categories.length]; + return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null; +} + +function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] { + if (!results.total) return []; + const cols = Math.max(1, state.cols); + let index = Math.min(state.selIndex, results.total - 1); + if (dx) index += dx; + if (dy) { + // Song hits are a single-column list; everything below them is a grid. + index += index < results.songs.length ? dy : dy * cols; + } + index = Math.max(0, Math.min(index, results.total - 1)); + return [{ type: "ui", patch: { selIndex: index } }]; +} + +/** ESC peels one layer off at a time rather than dumping you back at the top. */ +function escape(state: UiState): Action[] { + if (state.showHelp || state.openAlbumId !== null) { + return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }]; + } + if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }]; + if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }]; + return [{ type: "ui", patch: { category: null, selIndex: 0 } }]; +} + +export interface KeyEvent { + key: string; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; +} + +/** + * Translate one keypress. Returns the actions to run, or nothing when the key means + * nothing here - the caller only calls `preventDefault()` when something came back. + */ +export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] { + const { key } = event; + + if (event.ctrlKey || event.metaKey) { + switch (key.toLowerCase()) { + case "l": + return [{ type: "next" }]; + case "h": + return [{ type: "previous" }]; + case "k": + return [{ type: "volume", delta: VOLUME_STEP }]; + case "j": + return [{ type: "volume", delta: -VOLUME_STEP }]; + default: + return []; + } + } + + const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp; + + switch (key) { + case "Tab": { + const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!; + return [ + { type: "pop", freq: 380 }, + { type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } }, + ]; + } + case "/": + return [{ type: "ui", patch: { view: "browse" } }]; + case " ": + return [{ type: "pop", freq: 340 }, { type: "toggle" }]; + + case "ArrowRight": + if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }]; + return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }]; + case "ArrowLeft": + if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }]; + return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }]; + case "ArrowDown": + return browsing + ? moveSelection(state, results, 0, 1) + : [{ type: "volume", delta: -VOLUME_STEP }]; + case "ArrowUp": + return browsing + ? moveSelection(state, results, 0, -1) + : [{ type: "volume", delta: VOLUME_STEP }]; + + case "?": + return [ + { type: "pop", freq: 460 }, + { + type: "ui", + patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false }, + }, + ]; + case "F1": + return [{ type: "ui", patch: { showHelp: !state.showHelp } }]; + case "Escape": + return escape(state); + case "Backspace": + return state.search + ? [{ type: "ui", patch: { search: state.search.slice(0, -1), selIndex: 0 } }] + : []; + case "Enter": { + if (state.openAlbumId !== null) { + return [{ type: "play", albumId: state.openAlbumId, trackIndex: 0 }]; + } + const chosen = selectionAt(results, state.selIndex); + return chosen ? [chosen] : []; + } + default: + if (SEARCHABLE.test(key)) { + return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }]; + } + return []; + } +} diff --git a/web/src/lib/pop.ts b/web/src/lib/pop.ts new file mode 100644 index 0000000..270c3a8 --- /dev/null +++ b/web/src/lib/pop.ts @@ -0,0 +1,22 @@ +/** The mockup's click feedback: a short rising blip per interaction. */ + +let context: AudioContext | null = null; + +export function playPop(frequency: number): void { + try { + context ??= new AudioContext(); + const now = context.currentTime; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(frequency, now); + oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.8, now + 0.08); + gain.gain.setValueAtTime(0.15, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); + oscillator.connect(gain).connect(context.destination); + oscillator.start(); + oscillator.stop(now + 0.16); + } catch { + // No audio context before the first user gesture, and on some browsers never. + } +} diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts new file mode 100644 index 0000000..a2fa2a4 --- /dev/null +++ b/web/src/lib/search.ts @@ -0,0 +1,114 @@ +/** Browsing and searching the library, entirely in the browser. + +The whole index arrives in one response, so type-to-search has no round trip and feels +instant - which is the point of a keyboard-first UI. Ported from the design mockup. +*/ + +import type { Album } from "../api/types"; + +export type Filter = "all" | "music" | "book"; +export type Mode = "albums" | "tracks"; + +export interface SongHit { + album: Album; + index: number; + title: string; + duration: number; +} + +export interface Category { + key: string; + albums: Album[]; +} + +/** Diacritics and punctuation are noise when a child is hunting for letters. */ +export function normalize(value: string): string { + return (value ?? "") + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]/g, ""); +} + +export function inFilter(album: Album, filter: Filter): boolean { + if (filter === "book") return album.kind === "book"; + if (filter === "music") return album.kind === "music"; + return true; +} + +export function pool(albums: Album[], filter: Filter): Album[] { + return albums.filter((album) => inFilter(album, filter)); +} + +export interface BrowseQuery { + albums: Album[]; + search: string; + mode: Mode; + filter: Filter; + category: string | null; +} + +/** Which of the three lists the browse view is showing. */ +export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" { + if (query.mode === "tracks") return "tracks"; + if (query.search || query.category) return "albums"; + return "categories"; +} + +export function categoryMatches(query: BrowseQuery): Category[] { + if (listMode(query) !== "categories") return []; + const map = new Map(); + for (const album of pool(query.albums, query.filter)) { + const key = album.category; + let entry = map.get(key); + if (!entry) { + entry = { key, albums: [] }; + map.set(key, entry); + } + entry.albums.push(album); + } + return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de")); +} + +export function albumMatches(query: BrowseQuery): Album[] { + if (query.mode === "tracks") return []; + const needle = normalize(query.search); + let candidates = pool(query.albums, query.filter); + if (!needle && !query.category) return []; + if (query.category) candidates = candidates.filter((a) => a.category === query.category); + if (!needle) return candidates; + return candidates.filter((a) => normalize(a.title + a.artist).includes(needle)); +} + +/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */ +export const MAX_SONG_HITS = 40; + +export function songMatches(query: BrowseQuery): SongHit[] { + if (query.mode !== "tracks") return []; + const needle = normalize(query.search); + const hits: SongHit[] = []; + for (const album of pool(query.albums, query.filter)) { + album.tracks.forEach((track, index) => { + if (!needle || normalize(track.title).includes(needle)) { + hits.push({ album, index, title: track.title, duration: track.duration }); + } + }); + if (hits.length >= MAX_SONG_HITS) break; + } + return hits.slice(0, MAX_SONG_HITS); +} + +export interface Results { + songs: SongHit[]; + categories: Category[]; + albums: Album[]; + /** Selection indices run flat across songs, then categories, then albums. */ + total: number; +} + +export function results(query: BrowseQuery): Results { + const songs = songMatches(query); + const categories = categoryMatches(query); + const albums = albumMatches(query); + return { songs, categories, albums, total: songs.length + categories.length + albums.length }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..0913e5e --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,23 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import "./styles/app.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); + +// Registered after load so it never competes with the first paint. It is what makes +// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js +// for what it does and deliberately does not cache. +if ("serviceWorker" in navigator && import.meta.env.PROD) { + window.addEventListener("load", () => { + void navigator.serviceWorker.register("/sw.js").catch(() => { + // A plain http:// origin that is not localhost cannot register one. The app + // works exactly the same, it just cannot be installed. + }); + }); +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css new file mode 100644 index 0000000..8b07144 --- /dev/null +++ b/web/src/styles/app.css @@ -0,0 +1,188 @@ +/* Ported from claude-design/Dolphin Beats.dc.html. The palette is oklch throughout; + the tokens below are the handful of values that repeat. */ + +:root { + --ink: oklch(30% 0.04 210); + --paper: oklch(97% 0.01 210); + --accent: oklch(70% 0.16 340); + --accent-dim: oklch(78% 0.14 340); + --sea-deep: oklch(20% 0.045 210); + --shadow: oklch(15% 0.05 210 / 0.35); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Nunito", system-ui, sans-serif; + overscroll-behavior: none; + /* Installed on a phone this is an app, not a document: no rubber-banding, no + long-press callout, no accidental text selection when a small hand misses a + button. Text inside the parent-mode inputs opts back in below. */ + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +input, +textarea { + -webkit-user-select: text; + user-select: text; +} + +button { + font-family: inherit; +} + +@keyframes bubbleRise { + 0% { + transform: translateY(0) scale(1); + opacity: 0.55; + } + 100% { + transform: translateY(-120vh) scale(1.3); + opacity: 0; + } +} + +@keyframes dolphinBob { + 0%, + 100% { + transform: translateY(0) rotate(-2deg); + } + 50% { + transform: translateY(-10px) rotate(2deg); + } +} + +@keyframes dolphinSwim { + 0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); } + 15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); } + 30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); } + 45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); } + 60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); } + 70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); } + 74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); } + 85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); } + 95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); } + 100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); } +} + +.stage { + position: relative; + width: 100%; + /* dvh follows the iOS toolbar as it collapses; vh is the fallback for older + browsers, where it would otherwise leave a strip of white at the bottom. */ + height: 100vh; + height: 100dvh; + overflow: hidden; + background: linear-gradient( + 180deg, + oklch(55% 0.07 210) 0%, + oklch(38% 0.06 210) 45%, + var(--sea-deep) 100% + ); +} + +.bubble { + position: absolute; + bottom: -40px; + border-radius: 50%; + animation-name: bubbleRise; + animation-timing-function: linear; + animation-iteration-count: infinite; + pointer-events: none; +} + +.pill { + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 800; + padding: 9px 15px; + border-radius: 999px; + white-space: nowrap; + background: oklch(97% 0.01 210 / 0.16); + color: var(--paper); +} + +.pill[data-active="true"] { + background: var(--paper); + color: oklch(28% 0.04 210); + box-shadow: 0 4px 14px var(--shadow); +} + +.card { + backdrop-filter: blur(6px); + overflow: hidden; + cursor: pointer; + transition: transform 0.12s ease; + border: none; + padding: 0; + text-align: left; + font: inherit; + display: block; + width: 100%; +} + +.card[data-selected="true"] { + outline: 5px solid var(--paper); + outline-offset: 3px; + transform: translateY(-3px); +} + +.card[data-current="true"]:not([data-selected="true"]) { + outline: 4px solid var(--accent); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 24px; + max-width: 1180px; + margin: 0 auto; +} + +.round { + border-radius: 999px; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.key-cap { + font: 800 12px ui-monospace, Menlo, monospace; + background: var(--ink); + color: #fff; + padding: 5px 12px; + border-radius: 8px; + min-width: 56px; + text-align: center; +} + +.sheet { + background: var(--paper); + border-radius: 24px; + box-shadow: 0 20px 50px oklch(10% 0.04 210 / 0.5); +} + +.overlay { + position: absolute; + inset: 0; + background: oklch(15% 0.03 210 / 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 40px; +} + +input[type="number"], +input[type="range"] { + font: inherit; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..5ab68ea --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..4a961b2 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// In development the frontend runs on 5173 and the backend on 8081; everything under +// /api - including the websocket upgrade - is proxied there, so the browser sees one +// origin and there is no CORS to configure on either side. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { target: "http://127.0.0.1:8081", changeOrigin: true, ws: true }, + }, + }, + build: { outDir: "dist", emptyOutDir: true }, +});