Library, synchronization and self-update¶
The central library on the NAS, the boxes that synchronize with it and keep playing without it, the phone as a reader of the library, the self-update of the boxes, the play statistics and the release pipeline: how they work, and where the code is. Section 9 describes the test bed that proved the mechanics, and how to build it again. Related documents:
- architecture.md: the box itself (state machines, data model)
- rest-api.md: the endpoints of box and library
- deployment.md: the installer of a box and the playbook
- box.md: hardware, file layout, service
- roadmap.md: what is not verified yet
1. Goals and principles¶
| Topic | A box alone | With the library |
|---|---|---|
| Tags, albums, stations | only on the SD card of the box | in the library on the NAS; every box holds a copy |
| New album | upload to the box | upload to the library or to a box; a box fetches what it lacks |
| Backup | by a schedule set on its page, into backups-folder (default /srv/tinyjukebox/backups on the SD card), and as a download from its page (operations.md, section 10) |
everything a box needs to be rebuilt is in the library |
| Update of the box | with slots: the box fetches the newest release itself and can go back (section 6.16); without slots: ./update_box.sh on the PC pushes master |
the box fetches released versions from the library by itself and can go back |
| Number of boxes | one | any number: one catalog for all, positions per box |
| What was played | journal on the box, its last 1000 lines, counted on its page (section 7) | journal on the box, counted by the library, shown by library and box |
Principles the design follows:
- The box plays without network, and not later than a box alone. It asks the library only for what it lacks.
- Nothing entered on a box gets lost silently. Changes made without the library reach it later.
- The library is where changes meet. In a conflict its value wins, and the conflict is logged.
- Order is never decided by the clock. The Pi has no real-time clock; without WLAN its time is wrong.
- A defective version is not trusted – neither its code nor the data it wrote.
- Every way back works with plain files and one shell command, without network, web page or Python.
- The installer sets up the system of a box – with one line, or run by the Ansible playbook – for the first installation and as the rescue path.
- Data is migrated forward only, and only in the library (development.md, section 8).
2. Overview¶
flowchart LR
subgraph pc [PC]
clone["clone<br/>commit, push"]
end
subgraph gh [GitHub]
ci["workflow<br/>tests, build"]
rel["release<br/>package, checksum"]
end
subgraph nas [NAS]
lib["library<br/>catalog, albums, id table, journal"]
arch["archive of box versions"]
end
subgraph boxes [Boxes]
b1["box 1<br/>copy, outbox, slots"]
b2["box 2"]
end
clone -->|git push| ci
ci --> rel
lib -->|asks for a newer release| rel
lib --- arch
b1 <-->|changes, files, versions| lib
b2 <-->|changes, files, versions| lib
lib -.->|events| b1
lib -.->|events| b2
| Component | Runs on | Task |
|---|---|---|
Library (tinyjukebox-library) |
NAS | catalog, albums, covers, id table, journal, archive of box versions; web page for managing; interface for the boxes |
Box (tinyjukebox) |
every box | plays; holds a copy of the catalog and its own unconfirmed changes; fetches the albums it lacks |
tinyjukebox-slot and the start script |
every box, outside the slots | which version runs: install, switch, go back |
| Pipeline | GitHub | tests, builds the package for the NAS and the slot for the boxes, publishes a release |
Installer install.sh |
every box, once and when the setup changes | operating system, start script, first installation, rescue |
| Ansible playbook | PC | what only the developer needs, and runs the installer |
| Data | In the library | On a box |
|---|---|---|
| Catalog: tags, albums, stations | the merged state of all changes | a complete copy, plus own changes that are not confirmed yet |
| Audio files | all albums | the albums the box has needed so far |
| Positions | a copy per box | written by the box; per box, never shared between boxes |
Settings of a box: volume limits (volume.*) and name; of a phone also spotify-client. Not the update mode: it travels in the status report (section 4.13) |
a copy per box | written by the box; on the page of the library also the name, and for a phone the volume limits and spotify-client |
| Journal: what was played, changes of version | permanent | appended by the box and passed on |
| Versions of the box software | archive | three slots: a, b, fallback |
State of every version: use, manual, dontuse |
set here, by hand or because a box went back | a copy; a box changes a state by going back from a version |
2.1 The code¶
| Part | Where | Module or file |
|---|---|---|
| Library | NAS or PC, console script tinyjukebox-library |
src/tinyjukebox/library/ |
| Synchronization of a box | in tinyjukebox |
src/tinyjukebox/sync/ |
| Version and health check of a box | in tinyjukebox |
src/tinyjukebox/slots.py |
| Slot scripts | box, outside the slots | deploy/slot/ |
| First filling | PC, console script tinyjukebox-fill-library |
src/tinyjukebox/tools/fill_library.py |
| Package for the NAS | build_package.sh |
deploy/synology/ |
| Slot archive, pipeline | scripts/, .github/workflows/ci.yml |
src/tinyjukebox/
├── sync/
│ ├── __init__.py INTERFACE (version of the interface), who makes a change (actor)
│ ├── client.py HTTPS client that accepts exactly one certificate; resumable files, events
│ ├── handshake.py proofs of the bootstrap password, key of a box
│ ├── manifest.py files of an album with size and checksum
│ ├── util.py files written in one step, checksums, clock
│ ├── data.py BoxData: copy, outbox, working data, journal
│ ├── link.py Link: one pass of the synchronization, handshake, events
│ ├── files.py AlbumFiles (fetch on demand, send, space on the card), CardFiles
│ └── box.py BoxSync: connects all of it with the data of the box
├── library/
│ ├── store.py catalog with revisions, merge rules, id table, albums, versions
│ ├── journal.py journal store (SQLite)
│ ├── diagnostics.py what the library keeps of the logs of the boxes (SQLite, section 4.22)
│ ├── statistics.py play statistics from the journal
│ ├── releases.py query for releases, schedule, archive of box versions
│ ├── schema.py schema of the config file
│ └── service.py Library: web page, interface for the boxes, console script
├── diagnostics.py Collector: what a box sends of its log (section 4.22)
├── backup.py a backup: one archive of the data of a box or of the library (section 4.19)
├── backups.py backups by a schedule, of the library and of a box without one (section 4.19)
├── restore.py putting a backup back (section 4.12)
├── data_folder.py library and box with one folder (section 3.9)
└── slots.py Slots (the slot script as the application uses it), Health (health check)
A box and the library share tinyjukebox.data, the catalog operations of CatalogControl (control.py) and the web
server (web/server.py). Both are stdlib plus the dependencies of the package; the hardware libraries of a box are
the extra box.
3. The library on the NAS¶
The NAS is a Synology DS418j with DSM 7.4.1: a Realtek RTD1293, a 64-bit ARM processor (aarch64) whose name for
packages is rtd1296, and 1 GB of memory. Synology offers no Docker for it, and none is installed there by other
means. The library is therefore one Python process and is installed as a Synology package. It is never updated
automatically: a new package is installed by hand.
3.1 Application¶
The library is a program of its own with a script of its own, tinyjukebox-library – not a mode of tinyjukebox: a
server and a box are different things. It lives in the same repository and uses the parts both need. It can be run and
tested on the PC.
| Reused | data/ (tags, targets, sanity check); the web page with the tabs "Tags", "Statistics", "Tag management", "Albums", "Radio", "Spotify", "Upload", "System" and "Log"; users and roles |
| Left out | drivers, state machines, player |
| New | the interface for the boxes (section 4.13), the id table (4.6), the journal store and the statistics of all boxes (7), the archive of box versions and the overview of the boxes (6.13) |
3.2 Package¶
A Synology package is a tar archive with a fixed structure, installed in the Package Center with "Manual Install".
build_package.sh in the root of the repository creates it from the folder deploy/synology/; the Synology toolkit is
not needed. The library is never updated automatically: a new package is installed by hand.
tinyjukebox-library-<version>-<build>-<architecture>.spk
├── INFO name, version, architecture, minimum DSM version, port, description
├── package.tgz tinyjukebox and its dependencies; slot and installer for the boxes, the app for phones
├── scripts/ start-stop-status, postinst, preupgrade, postupgrade, preuninst, …
├── conf/privilege the package runs as a user of its own, not as root
├── conf/resource shared folder, firewall port
├── WIZARD_UIFILES/ dialog during the installation: admin password, bootstrap password
└── PACKAGE_ICON.PNG, PACKAGE_ICON_256.PNG
- User of its own: DSM 7 runs a package without root. The port is therefore above 1024 (the box uses 443 with a capability of the service unit).
- Version: DSM wants the form
<version>-<build number>, for example0.8.0-0001. The version is the one of Tiny Jukebox; the build number counts the packages of one version. - Shared folder: DSM creates a shared folder
tinyjukeboxwhen the package is started for the first time, and the package createsalbums/,images/andjson/in it. The package reaches the folder through the link/var/packages/tinyjukebox-library/shares/tinyjukebox. Albums can also be put there with SMB or File Station, and Hyper Backup includes the folder. - Data across updates: certificate,
users.yamland config live in the package'svarfolder, which an update keeps. - Entry in the DSM main menu: opens the web page in a new browser tab. The page keeps its own login.
./build_package.sh [BUILD [--slots DIR]] creates dist/tinyjukebox-library-<version>-<build>-rtd1296.spk from the
folder deploy/synology/. Section 3.6 says how a package is written.
package.tgzholds tinyjukebox and its dependencies inlib/, as ready-made files for Python 3.12 on aarch64 (uv pip install --target … --python-platform aarch64-manylinux_2_28). Nothing is installed on the NAS; the start script setsPYTHONPATH.slots/holds the slot archive of the same version fromDIR(default:dist), if there is one, the installer of a box of that version (DIR/install.sh, astinyjukebox-install-<version>.sh) and the app for phones of that version (DIR/tinyjukebox-app-<version>.apk): the library hands them out (section 4.13).- The installation dialog (
WIZARD_UIFILES/install_uifile, andinstall_uifile_gerwhen DSM is German) asks for the password of the useradminand for the bootstrap password.postinstwritesvar/library.yamlandvar/ssl/users.yamlon the first installation; an upgrade keeps both. - The port is 8866, fixed when the package is built:
INFOand the firewall file of DSM name it. - For new box versions the library asks the repository of the releases. The config of the package names the token
file
var/github-token: without a token – set on the page of the library, or in that file – it does not ask, also not a public repository, for which an empty file is enough (section 8.2).
3.3 Python for the package¶
The library runs with the Python 3.12 of SynoCommunity (3.12.13 on the NAS); the Python of DSM itself is 3.8 and too old.
- The package names
python312as a package it depends on and uses it through/var/packages/python312/target/bin/python3, the path that does not depend on the volume. - The package brings Tiny Jukebox and its dependencies as a folder
lib, installed when the package is built from ready-made files for this Python and processor. Nothing is installed on the NAS, so the installation needs neither internet nor a compiler. - Tiny Jukebox is developed with Python 3.13 and allows 3.11 or newer. The tests of the workflow also run with 3.12, the version of the NAS.
- The hardware libraries of a box –
pyalsaaudio,rpi-lgpioanddbus-fast– are the extraboxand not part of the package: their markers for aarch64 or Linux (pyproject.toml) would select them on the NAS too.
3.4 Restrictions¶
- DSM shows a warning for every package that does not come from Synology. A package cannot be signed by its author.
- A package is built for one platform. Another NAS needs another build.
- Login with the DSM users is left out: more work, and it would tie the page to DSM.
- The page has a self-signed certificate. The certificate of DSM can be used with a reverse proxy configured in DSM; the package does nothing for it.
3.5 First filling¶
The library starts empty. A command run on the PC fills it from what is at hand there: tags, albums with their files, stations and their covers. It keeps the ids of the existing data, so that box and library agree from the start, and it can be repeated: what is there already is skipped. The positions come from the box itself with its first synchronization.
uv run tinyjukebox-fill-library https://nas:8866 --user admin \
--json ~/tinyjukebox-sim/json --albums /path/to/albums --images /path/to/images
- Asks for the password and shows the fingerprint of the certificate of the library for confirmation
(
--fingerprintskips the question; the library logs its fingerprint at the start). - Sends tags and targets under their ids; entries the library has already are skipped.
- Computes the manifest of every album, asks which files the library lacks and sends those; sends the covers of albums and stations.
- Can be repeated at any time.
3.6 Writing the package¶
What has to be known to write or change the package. It comes from the developer guide of Synology and from the way
SynoCommunity builds its packages. The files are in deploy/synology/, and build_package.sh packs them.
The file:
tinyjukebox-library-<version>-<build>-<architecture>.spk tar, not compressed
├── INFO lines of the form key="value"
├── package.tgz tar with gzip; its content becomes the folder target on the NAS
├── scripts/ seven scripts, all required
├── conf/privilege required
├── conf/resource
├── WIZARD_UIFILES/ the installation dialog
├── PACKAGE_ICON.PNG 64 x 64
└── PACKAGE_ICON_256.PNG 256 x 256
- The entries lie directly in the archive, as Synology's own tool packs them: no leading
./and no entry for the folder itself. The same holds forpackage.tgz. - Owner root; files 644, folders and scripts 755. DSM hands the unpacked files to the user of the package.
- The name of the file means nothing to DSM.
build_package.shreplaces@VERSION@and@PORT@in the text files, writes the fileVERSIONintopackage.tgz, copies two of the menu icons as package icons and puts the checksum intoINFO. Plaintaris enough.
INFO:
| Key | Here | Meaning |
|---|---|---|
package |
tinyjukebox-library |
identity; also the name of the folder /var/packages/tinyjukebox-library and of the user. A colon, a slash, <, >, = or a vertical bar is not allowed in it |
version |
0.8.0-0001 |
<version>-<build number>: numbers only, separated by ., - or _. A package with a higher one is installed as an upgrade |
os_min_ver |
7.0-40000 |
at least this value, or DSM 7 does not install the package |
arch |
rtd1296 |
the platform of the DS418j. noarch fits only a package without compiled files |
description, maintainer |
required | |
displayname, displayname_ger |
Tiny Jukebox Library, Tiny Jukebox Bibliothek |
the name in the Package Center; a key with the language of DSM at its end (_ger) is taken when DSM is set to that language. The same for description |
install_dep_packages |
python312 |
packages that must be installed before. Several are separated by a colon; a version can follow, as in python312>=3.12 |
dsmuidir |
ui |
the folder in package.tgz with the menu entry |
dsmappname |
the id of the menu entry | what "Open" in the Package Center opens |
adminprotocol, adminport |
https, 8866 |
the address of the page. DSM checks during the installation that the port is free (checkport, on by default) |
checksum |
MD5 of package.tgz |
conf/privilege is {"defaults": {"run-as": "package"}}; DSM 7 accepts nothing else from a foreign package. DSM
creates a user and a group with the name of the package (other names: username, groupname), runs every script and
the process as this user and gives it the folders of the package.
conf/resource names what the package needs from DSM. Each key is a "worker":
| Worker | Declares | Behaviour |
|---|---|---|
data-share |
the shared folder tinyjukebox, writable for the user of the package |
DSM creates it when the package is started, not when it is installed, and on the volume of the package (tried: /volume3/tinyjukebox). A folder of that name that exists already is used. It is never removed. The package reaches it through the link /var/packages/tinyjukebox-library/shares/tinyjukebox |
port-config |
the file tinyjukebox-library.sc in target: a section name, title and desc in English, port_forward, dst.ports="8866/tcp" |
DSM lists the port in its firewall as an application |
The scripts are run by /bin/sh as the user of the package. All seven must exist; one with nothing to do is exit 0.
| Script | Called | A result other than 0 |
|---|---|---|
preinst |
before the files are unpacked | aborts the installation |
postinst |
after the files are in target |
the package counts as damaged |
preupgrade, postupgrade |
before and after an upgrade, from the new package | aborts the upgrade; the package counts as damaged |
preuninst, postuninst |
before and after the removal; during an upgrade also from the old package | preuninst aborts the removal |
start-stop-status |
with start, stop and status; before the first two also with prestart and prestop |
status answers 0 for running and 3 for not running |
postupgrade notes the upgrade in var/package.log and brings the installed config up to date where a key was
renamed: cards-folder of a config written before 0.29 becomes images-folder. The config itself is written only
once, by the first installation.
The order:
installation preinst, postinst, start
upgrade stop (old), preupgrade (new), preuninst (old), postuninst (old),
preinst (new), postinst (new), postupgrade (new), start (new)
removal stop, preuninst, postuninst
- Variables that DSM sets for the scripts:
SYNOPKG_PKG_STATUS(INSTALL,UPGRADE,UNINSTALL,START,STOP; empty while the NAS starts or shuts down),SYNOPKG_PKGVER,SYNOPKG_OLD_PKGVER(during an upgrade),SYNOPKG_PKGDEST(target),SYNOPKG_PKGVAR(var),SYNOPKG_DSM_LANGUAGEandSYNOPKG_TEMP_LOGFILE: what a script writes into that file, the Package Center shows to the user. - Paths: the scripts use the fixed paths below, not their own location, because DSM unpacks a package into a temporary folder during the installation.
- The process:
startstarts it in the background and notes its number invar/server.pid; its output goes tovar/server.log.stopends the process with this number,statuslooks whether it lives. The packages of SynoCommunity do the same. Nothing restarts a process that crashes.
The folders on the NAS are links in /var/packages/tinyjukebox-library/ and belong to the user of the package. They lie
on the volume of the package, here /volume3:
| Link | Lies in | Content | Removed |
|---|---|---|---|
target |
@appstore/tinyjukebox-library |
the content of package.tgz |
by every upgrade and by the removal |
var |
@appdata/tinyjukebox-library |
data that stays; tried: an upgrade keeps it | never |
etc |
@appconf/tinyjukebox-library |
config that stays | never |
tmp |
@apptemp/tinyjukebox-library |
temporary files | by every upgrade and by the removal |
home |
@apphome/tinyjukebox-library |
private folder of the user, mode 0700 | never |
shares/tinyjukebox |
/volume3/tinyjukebox |
the shared folder | never |
- DSM puts folders named
@eaDirinto a shared folder. The library skips them. - On the NAS,
/var/log/packages/tinyjukebox-library.logholds what the scripts printed and/var/log/synopkg.logwhat the Package Center did.
The menu entry is the folder ui in package.tgz, which DSM links into its own web folders. Its file config declares
one entry of the type url with title, desc, icon, protocol, port and url; DSM takes the host name from the
address of DSM in the browser and opens the page in a new tab or window. icon is images/icon_{0}.png, and DSM
replaces {0} by 16, 24, 32, 48, 64, 72 or 256, so seven files are needed. They are made from
docs/images/logo-icon.svg. Without allUsers only administrators see the entry. protocol and port are not in the
guide; they are written as SynoCommunity writes them, and they work on the NAS.
- The installation dialog:
WIZARD_UIFILES/install_uifileis a JSON file with steps and fields (textfield,password). Each field has a key, and its value reachespreinstandpostinstas an environment variable of that name. The packages of SynoCommunity use this form. For DSM 7.2.2 and later the guide describes a second form with a compiled Vue component; it is not used. - A restart after a crash is not set up. A package can bring a systemd unit in
conf/systemd/, which DSM installs.
Sources:
| Topic | Source |
|---|---|
The file and INFO |
developer guide of Synology: structure, required keys, optional keys |
| Scripts | developer guide: scripts and their order, variables, messages |
conf |
developer guide: privilege, shared folder, port, systemd unit |
| On the NAS | developer guide: folders, menu entry, installation dialog |
| How SynoCommunity does it | its build files: the package, user, resources, menu entry, start and stop |
3.7 First installation on the NAS¶
The steps from an empty NAS to a filled library. <nas> is the name or address of the NAS, var the folder
/var/packages/tinyjukebox-library/var.
- Before. The Python 3.12 of SynoCommunity is installed on the NAS. The vault of the playbook has the bootstrap
password,
vault_library_bootstrap– e.g. made withopenssl rand -hex 24;postinstwrites any password into the config as it was typed (with the YAML library of the package, checked by reading it back). No key is copied to the NAS: the library creates its certificate at its first start, and the key of a box when the box connects. - Fetch the package of the latest release on the PC:
./fetch_release.sh
It puts the files of the release into dist/ – the package and the slot archive with its description and
signature – after it checked them against the checksums of the release and the archive against its signature;
./fetch_release.sh 0.15.0 fetches this version. gh must be logged in. The
package of a release brings the slot of its version for the boxes. One built with ./build_package.sh does
so only if the slot archive lies in dist.
3. Install it: Package Center of DSM, "Manual Install", choose the file, confirm the warning about packages of
third parties. The dialog asks for the password of the user admin of the page (at least 8 characters) and for
the bootstrap password (at least 16, the one of the vault).
DSM never removes var, not even when the package is removed. If var/library.yaml and var/ssl/users.yaml of
an earlier installation are there, they stay and the answers of the dialog are not used. The data folder
var/data stays as well: with it the library keeps its catalog and its boxes, and a bootstrap password of the
dialog in a new var/library.yaml replaces the one before (section 4.14; not over data of a version before 0.45,
which keeps its password once more – set it on the page then). To start from nothing, remove the
package first, then delete both files and var/data, then install. A package that is installed over one that
is still there is an upgrade: no dialog, and postinst writes neither file – without var/library.yaml the
library does not start.
4. Check: the Package Center shows the package as running, and https://<nas>:8866 opens the page (self-signed
certificate; the library logs its fingerprint at the start, in var/library.log). Log in as admin. If the
start failed, the reason is in var/server.log; what the scripts of the package printed is in
/var/log/packages/tinyjukebox-library.log.
5. Users: the library has the user admin only. Library and boxes keep their own users; the others are added
on the page, tab "System", section "Users".
6. Fill it (section 3.5) with the data of the box, from the PC:
rsync -a pi@tinyjukebox.local:/srv/tinyjukebox/json/ ~/box-copy/json/
rsync -a pi@tinyjukebox.local:/srv/tinyjukebox/albums/ ~/box-copy/albums/
uv run tinyjukebox-fill-library https://<nas>:8866 --user admin \
--json ~/box-copy/json --albums ~/box-copy/albums --images ~/box-copy/json/images
The tabs "Albums", "Tags" and "Radio" then show what the box has, and the albums lie in the shared folder
tinyjukebox/albums. Titles and covers of the albums can be set for all of them at once with
tinyjukebox-import-covers, with the address of the library (operations.md, section 5).
7. Token for new box versions: without one the package asks for no release, also not in a public repository
(section 8.2). For a public repository an empty file var/github-token is enough, owned by the user
tinyjukebox-library; then stop and start the package. For a private one: on the page of GitHub create a
fine-grained token for this one repository with "Contents: Read-only" (section 6.16) – neither gh nor the
interface of GitHub can create one – and enter it on the page of the library, tab "Versions", section "Access
to the releases". The library asks GitHub with it before it keeps it, and looks for a release at once. (Without
the page: store it as var/github-token, owned by the user tinyjukebox-library and readable by nobody else,
then stop and start the package.)
The boxes follow: a new box with the line of the section "Add a box" in the tab "Boxes" (deployment.md, section 5.1); a box that is set up already gets the address of the library on its page or from the playbook (section 4.2). A box that still runs the code of its repository gets its first slot from the installer (deployment.md, section 5).
A newer package is installed the same way, without the dialog: var and the shared folder stay as they are.
From the PC, ./update_library.sh [<nas>] [<version>] does it. <nas> is the name or address of the NAS, and DSM is
then https://<nas>:5001; another address of DSM is given in its place; without <nas> it is the DSM of the vault
(below). The script takes the package of the newest release (or of <version>) from dist/, fetching it with
fetch_release.sh if needed, logs in to DSM as an administrator – user and password from DSM_USER and DSM_PASSWORD,
otherwise from the vault for its DSM, otherwise it asks; with two-factor login it asks for the code, and it stores none
of them – and sends what the Package Center sends for "Manual Install": the upload, then check and upgrade in one
request (SYNO.Core.Package.Installation). It sends the password only to the key of DSM that was accepted the first
time it was shown, or to the one of --pin, or to a certificate signed by the authority of --cacert (section 8.2).
Synology does not document these requests; they are read from the Package Center of DSM 7.4
(/webman/modules/PkgManApp/PkgManApp.js on the NAS). The script refuses a NAS without the library: the first
installation has the dialog.
The DSM of the library in the vault: the vault of the playbook (deploy/ansible/group_vars/all/vault.yaml, the
file and the password of update_box.sh) may hold the address of DSM, a user who is an administrator and the
password. Then ./update_library.sh and ./release.sh need no NAS and ask only for the password of the vault
(dsm_vault.sh; with ANSIBLE_VAULT_PASSWORD_FILE for none):
vault_dsm_address: 192.168.11.6 # as <nas> above
vault_dsm_user: admin
vault_dsm_password: '...' # in quotes: YAML reads 0123 as a number
User and password go to this DSM only: for another <nas> the scripts ask for them. Without the keys, without the
file or without ansible-vault they ask as well, and <nas> must be given.
3.8 The library on the PC¶
uv run tinyjukebox-users -f ~/tinyjukebox-library/ssl/users.yaml add admin --role admin # once
./start_library.sh # https://localhost:8866/
./start_sim.sh # the box on the PC
On the page of the box, tab "System", enter https://localhost:8866 and the bootstrap password of
deploy/config/library-sim.yaml, then restart the box. The box appears in the tab "Boxes" of the library. This
config names no repository of releases: the library on the PC fetches no versions for the boxes (section 8.2).
Config file of the library (-c), sections logger and library:
| Key | Meaning |
|---|---|
data-folder, albums-folder, images-folder |
see section 4.2 |
json-folder |
generated working files (default: json in the data folder) |
backups-folder |
the backups the library makes by itself, until another folder is set on its page (default: backups beside the albums folder; section 4.19) |
playlist-filename |
default playlist.m3u |
bootstrap-password |
taken over into settings.json at the first start, and whenever it names another password than at the start before; one set on the page stays until then (section 4.14) |
platform |
platform of the boxes whose versions are fetched, e.g. aarch64-debian-trixie |
slots-folder |
folder with the slot that the package brings along |
rest-api |
as for a box (configuration.md, section 5) |
releases |
repository, token-file, kind, api, quiet, busy, busy-for, back-after, keep-versions, keep-bytes. Without repository the library never asks; which token it sends, and when it does not ask: section 8.2. A number of versions set on the page (tab "Versions") comes before keep-versions (default: 5; section 6.13) |
diagnostics |
days (default: 30) and records-per-box (default: 20,000): what the library keeps of the logs of the boxes (section 4.22) |
3.9 The library in Docker and with one folder¶
Elsewhere than on this NAS the library runs from the Docker image or from the package on PyPI, with everything below one folder (configuration.md, section 1):
docker run -d --name library --restart unless-stopped -p 8866:8866 \
-v library:/data -v /path/to/albums:/data/albums ghcr.io/tgd1975/tinyjukebox
tinyjukebox-library --data ~/library # with pip install tinyjukebox
- One image, two roles (
ghcr.io/tgd1975/tinyjukebox, tags<version>andlatest,linux/amd64andlinux/arm64): without a command, or withlibrary, the library on port 8866; withboxa box without hardware and without sound on port 8443 – tags, buttons and LED are on its page; with any other command that command, e.g.tinyjukebox-users. Library and box are one Python package with two commands; onlyffmpegis for the box alone. - Everything that must survive lies below
/data: one volume, and if wanted the albums as a folder of the host. The backups the library makes by itself go to/data/backupsuntil another folder is set on its page – a mounted folder of the host, if they are to outlive the volume. A mounted folder must be writable for the user of the image (uid 1000). - The passwords: the password of the user
adminfromTINYJUKEBOX_ADMIN_PASSWORD, otherwise generated and printed once on the error output (docker logs library), not into the log file; it counts at the first start only. The bootstrap password fromTINYJUKEBOX_BOOTSTRAP_PASSWORD, otherwise set in the tab "Boxes"; it counts at the first start and whenever it is another one than at the start before (section 4.14). Instead of the password itself,TINYJUKEBOX_ADMIN_PASSWORD_FILEandTINYJUKEBOX_BOOTSTRAP_PASSWORD_FILEname a file that holds it: a variable stays in the environment of the container for its whole life, wheredocker inspectand every process in it see it. - The image brings the slot of its version and its installer, as the package does: the library can hand a version
to a box before it has asked the releases, and a box joins with the line of the tab "Boxes" – its address must be
the one of the host, not the name of the container. Newer versions it fetches by itself, from the releases of the
project. It brings no app for phones:
/app.apkanswers404until the library has fetched a release with its app. - Updating is pulling a newer image. Nothing does that by itself, as on the NAS; the page shows that a newer version exists.
- A box without hardware for trying:
docker run -d --name box -p 8443:8443 -v box:/data ghcr.io/tgd1975/tinyjukebox box. It does not update itself. After a backup was put back it ends with status 75 to be started again, and the entry point of the image starts it again, asstart_sim.shdoes; the library starts itself again. - Both together:
deploy/docker/compose.yamlstarts a library and a box linked to it; the box reaches the library under the name of its service. The two passwords are the filesadmin-passwordandbootstrap-passwordnext to it – secrets of compose, mounted below/run/secretsand named by the variables…_FILE, never in the environment of the containers. They must be readable for the user of the image (uid 1000) and are not in git..env(fromexample.env) only names another version than the newest. - What is in the image (
deploy/docker/Dockerfile): the slim Debian image of Python 3.13 – pinned to its digest, dependabot proposes the next one –,ffmpeg, the wheel of the version with the versions of its dependencies fromuv.lock, installed with their checksums, the slot and the installer of the version; not the extrabox, no compiler, not the repository. It is built from the wheel of the release, not from the source tree: image and PyPI hold the same files (scripts/build_image.sh). A health check asksGET /api/identity. - No Docker for a real box: GPIO, sound and Bluetooth would have to be passed into the container, and the slots already do what an image would be for.
4. Synchronization¶
4.1 Kinds of data¶
| Data | Written by | Compared with | Conflict possible |
|---|---|---|---|
Catalog: tags.json, targets.json |
the library and every box | checksum per file (towards the box), revision per entry (towards the library) | yes, section 4.5 |
| Positions | the box, per box | revision per entry | only with a deliberate change in the library |
| Settings of a box | the box | revision per entry | as for positions |
| Journal | the box, append only | running number per box | no |
| States of the versions (section 6.2) | the library; a box when it goes back from a version | revision per entry | yes: the library wins |
4.2 What is stored where¶
A box without a library is not touched: its json files stay the truth, and nothing is recorded. A box works with a
library as soon as identity/library exists – written by the installer (--library, also from the playbook) or entered
on the page (tab "System") – from its next start. From then on it stores two other things, and the json files it works
with are derived from them (folders sync and identity next to the folder of the json files, or tinyjukebox/sync in
the config):
sync/
├── copy/tags.json, targets.json, images.json, box.json as the library delivered them
├── outbox own changes that the library has not confirmed, one JSON line each
├── ids.json provisional id -> final id, while the outbox still needs it
├── numbers.json last numbers assigned: outbox, provisional targets, journal, start
├── journal lines that the library has not confirmed yet
├── manifests/<id>.json files of the albums that are or were on this box
├── played.json albums in the order in which they were played last
├── attached the box works with a library
└── fit whether box and library fit together
identity/
├── box-id internal id, generated on the first start
├── library address of the library
├── bootstrap bootstrap password, deleted after the handshake
├── key key of this box
└── library.crt certificate of the library
- The copy holds the catalog and the positions and settings of this box exactly as the library delivered them.
- The outbox holds the changes made on this box – a tag linked, a position moved on – until the library has confirmed them. It survives restarts. A confirmed change leaves the outbox, because it is part of the copy then.
- What the box works with is the copy with the outbox applied on top. A change made on the box takes effect at once, with or without a connection – there is no special case for "offline".
- The working files are generated. Every version of the box software builds them from copy and outbox in its own format. This is what lets a box rebuild its JSON files after a fallback, also without a connection, and it means that the box needs no migration code. Formats are migrated in the library, forward only.
The first start with a library (BoxSync._attach): everything the box has becomes a change that the library has
not seen. Every target gets a provisional id, its old id with the id of the box in front (b3f2a1c9-7); tags and
positions follow. A tag that is linked to nothing that exists becomes unlinked. The library then recognizes an album
with the same tracks or a station with the same name and address and answers with its id; everything else it takes
over. From then on the json files of the box are generated from copy and outbox on every start.
Library (data-folder, albums-folder, images-folder of its config):
data/
├── catalog/tags.json, targets.json, images.json what every box receives whole
├── boxes/<box id>.json key (hashed), positions, settings, answers, waiting changes, albums on their way
├── ids.json id table: box/target/number -> final id, never emptied
├── manifests/<id>.json files of every album
├── broken.json broken files of the albums: found by the library, reported by boxes
├── versions.json versions of the box software with their states
├── archive/ slot archives
├── releases.json state of the query for releases
├── journal.sqlite journal lines of the boxes, records of the library
├── diagnostics.sqlite what the boxes delivered of their logs (section 4.22)
├── images-seen.json size and time of change of every cover when its checksum was taken
├── backups.json backups: folder, schedule, when the last one was made and how it went
├── settings.json bootstrap password, tokens for scripts (hashed), token for the releases
└── json/ what the library itself works with, generated on every start
albums/ the albums; .tinyjukebox-staging/ holds albums that boxes are sending
images/ the images folder: covers of the albums and of the radio stations
An entry of a catalog file, of the positions and of the settings has the same form:
{"value": {…}, "rev": 3, "by": "library", "since": 2, "who": "web:anna", "when": "2026-10-06T10:00:00Z"}
valueis the entry in the format of the json files of a box (architecture.md, section 6);nullmarks a deleted entry.revrises with every change.byis the side that changed the entry last –libraryor the id of a box – andsincethe revision before its first change in a row; this is how two changes of one box in a row are no conflict.- An album target also has
album:identity(checksum over its tracks, for recognizing the same album),checksum(over all files and the order, for noticing a change),files,sizeandlacks(files the library itself lacks). As soon as the library has all files it addstracksandlength, the number of the tracks and their playing time in seconds: a box shows them also for an album that it has not fetched. images.jsonhas no entries:imagesis the checksum and size of every cover in the images folder (album-<8 characters>.jpgof an album,station-….jpgof a radio station,spotify-….jpgof an entry of Spotify), or the mark of a removed one. The entry of an album, a station or an entry of Spotify names its cover (cover). Nothing else in the folder is listed or sent.
4.3 From the library to the box¶
- The box compares the checksum of each file of its copy with the checksum of the library: on start, on an event from the library (section 4.9), and when it meets an unknown tag.
- If a checksum differs, the box fetches the whole file. There are no partial updates: the files are small, and a whole file repairs every difference – a damaged file on the box as well as a library restored from a backup.
- Only the copy is replaced. The outbox stays on top, so a change made on the box is never lost by a download.
- The box receives the whole catalog, not only the tags it has seen. Only audio files are fetched on demand. This keeps the comparison simple (one checksum per file instead of a question per tag), lets the web page of the box show everything, and makes every tag whose album is on the box work without WLAN.
- An answered change leaves the outbox only after the copy was fetched, so that nothing disappears between the answer and the copy.
4.4 From the box to the library¶
Every entry of the outbox carries
- a running number of the box. If a confirmation gets lost and the box sends an entry again, the library recognizes it and answers as it did the first time;
- the revision of the entry it is based on. Every entry of the catalog, every position and every setting has a revision that the library raises with each change;
- who made it – a person on the web page or the console, or the software by itself – and the time, with a note whether the clock was synchronized.
| Revision in the library | Meaning | Result |
|---|---|---|
| equal to the one the change is based on | normal case | the library applies the change and raises the revision |
| higher | someone else changed the entry in the meantime | catalog: conflict, section 4.5. Positions and settings: the library changed the entry on purpose (reset on its page, or data of a defective version discarded) and wins |
| lower | the library lost data, e.g. it was restored from a backup | the box wins |
Several changes of one box in a row are no conflict. A box without a connection may change the same entry twice; the second change is then based on a revision that the first one has raised in the meantime. The library therefore notes for every entry which box changed it last and since which revision. A change of the same box that is based on a revision of this span is applied.
A change is the whole entry, for tags, targets, positions and settings alike. A position reaches the library with a change of track, a pause or the end, not every few seconds.
Changes are recorded where they become visible: Tags, Targets, Activities and VolumeLimits tell
a listener which entry changed (Mirror in data/common.py), whoever changed it. BoxSync writes the whole entry
into the outbox; an earlier change of the same entry that is still there is replaced. Who made the change comes from
tinyjukebox.sync.actor(): web:<user> in a request of the page, console in a command of the console, otherwise
software. The other direction uses sync() of the collections, which updates the objects in place and does not
call the listener.
4.5 Conflicts¶
A conflict exists only if both sides changed the same entry since their last common state.
| Situation | Result |
|---|---|
| The box created something the library does not know | the library takes it over |
| The box changed an entry that the library has not touched since | the library takes it over |
| Both did the same | nothing to do |
| The box created an unknown tag by itself as "not linked", the library has it linked already | the state of the library stays; this was no deliberate change |
| Both changed the same entry differently | conflict |
| The box changed something the library deleted, or linked to something deleted | conflict |
Rule: the value of the library wins. The change of the box is dropped. Which change was the later one cannot be told without a reliable clock, so the system only detects that both changed and does not guess.
A deleted entry stays in the catalog as a mark with its revision. This is how a late change to it is recognized as a conflict instead of being taken for a new entry. The working files of a box leave the marks out.
| Level | When |
|---|---|
| WARNING | a conflict: both sides changed the same entry, the library won. An expected case, solved by the rule |
| ERROR | the library rejects a change as invalid: it violates the schema, refers to an id that cannot be resolved, or a file fails its checksum repeatedly. This should not happen |
- Content of the log entry: box, entry, what the box wanted, what the library has, who made the change (web user or console) and when – with a note whether the clock of the box was synchronized. This is enough to repeat the dropped change by hand.
- Where: the library logs the conflict and reports it to the box in its answer; the box writes it to its own log. The library also keeps it in the journal (section 7), because a log file rotates (4 × 1 MB) and a conflict may drop out before anyone reads it.
- Playback is not interrupted. If the conflict concerns the tag that lies on the box, the value of the library applies from the next time the tag is placed.
The rules as Store.apply() checks them for a change of an existing entry, in this order:
| Case | Answer |
|---|---|
| the entry has this value already | applied, nothing changes |
| the box created a tag by itself and the library knows it | applied, the value of the library stays |
| based on the current revision, or all changes since were made by this box | applied |
| a position or setting that the library changed | conflict without a warning: the library changed it on purpose |
| anything else | conflict: WARNING with both values, record in the journal |
Further answers: waiting (the change depends on an album whose files have not all arrived; the box keeps it),
rejected (ERROR: the value violates its schema or refers to an id that does not exist), discarded (written with
a version that this box reported as defective).
- A change that is sent again gets the answer of the first time (
resultsof the box, by running number). - The first filling stores its entries with revision 0. A box that brings a change of the same entry along therefore wins as long as nobody changed the entry in the library.
- The library never deletes audio files because a box deleted an album: the entry becomes a mark, the folder stays and is offered as a new folder on the page.
4.6 Provisional ids¶
On a box alone a new album or station gets the highest id plus one (Targets.add_target in data/targets.py). If a
box and the library each created something that way while they cannot reach each other, both would assign the same
number to different things.
- An entry created on a box gets a provisional id: the internal id of the box (section 4.7) and a running number,
e.g.
b1-3– in this documentb1stands for the internal id of a box. The schema allows strings already. Tags are not affected: their key is the UID. - The library resolves it. It assigns the final id and remembers the resolution in the id table, keyed by the triple box id, id type, id. The same triple always gets the same answer, however often the box asks or sends the same change.
- Entry and resolution are stored in one step. The new entry itself carries a checksum of the triple it was created under. If the power fails between the catalog and the id table, the library finds the entry by this checksum and does not create it a second time. The provisional id itself appears nowhere in the catalog.
- The id table is never emptied. Latecomers with the provisional id – journal entries, changes that were in the outbox before the resolution – are resolved by the library itself.
- The box asks until it is done. For every provisional id that still occurs locally it asks the library for the final id, applies it and asks again until none is left. After a power cut the next start finds the remaining ones and continues.
- What "applying" means on the box: the catalog arrives with the final id by itself, with the next copy. The positions must be rewritten – otherwise the album starts from the beginning, because the box discards a position whose album differs from the album of the tag (architecture.md, section 2.1). If the library gave the folder of the album another name and the album is playing, the box waits until it no longer plays.
- A provisional number is never assigned twice, otherwise the library would resolve a new entry to an old id.
Three safeguards: the box remembers the last number it assigned (
sync/numbers.json); the library tells the box the highest number it knows of it; and a box that is set up again gets a new internal id, and with it a new range of numbers (section 4.7).
A provisional id that is still visible anywhere on a box therefore means exactly: not settled with the library yet.
sequenceDiagram
participant B as Box b1
participant L as Library
Note over B: no connection
B->>B: add a station as b1-3, outbox entry 41
B->>B: link a tag to b1-3, outbox entry 42
Note over B,L: connection is back
B->>L: entry 41, new station b1-3
L->>L: create station 17 and remember that b1, target, 3 is 17
L-->>B: applied, b1-3 is 17
B->>L: entry 42, tag linked to b1-3, based on revision 7
L->>L: resolve b1-3 to 17, revision 8
L-->>B: applied
B->>L: checksum of the catalog differs, fetch the file
B->>B: rewrite positions from b1-3 to 17
4.7 Identity of a box¶
- Every box has an internal id that is generated automatically and never changes. Nobody types it. It is the first part of the provisional ids and the key of the positions, settings and journal entries of the box in the library.
- Every box has a name for people, which can be edited at any time. The pages show the name; changing it changes nothing else.
- Positions belong to the internal id. A tag may move between boxes, the playback progress does not.
- The box generates its internal id itself on its first start, so that it can create entries before it has ever met the library. With its first contact it makes the handshake of section 4.14 and gets a key of its own. A box that is set up again, e.g. on a new SD card, is a new box with a new id; on the page of the library it can take over the positions and settings of an old one.
4.8 Albums and other files¶
A manifest defines an album: its files with size and checksum, and the playlist. Each side compares the manifest with what it has; a missing file is copied from the side that has it. This is the same code in both directions, and in the worst case one file of an album travels to the box and another one to the library.
From the library to a box, on demand:
- A tag points to an album whose files the box lacks. The box fetches the manifest.
- It downloads the files in the order of the playlist, beginning with the file that is to be played.
- It starts playing as soon as that file is complete.
- If the next track has not arrived yet, the player waits at the end of the track. "Next" on a missing track pulls this track forward in the download.
A file is stored under a temporary name and renamed only after its checksum has been verified, so a power cut in the middle of a download leaves no half file. An album that is playing is never updated.
A download that broke off goes on where it stopped: the box asks for the rest (Range: bytes=<size of the part>-).
The library answers 206 with the rest; for a part that is complete already, 200 with Content-Range and nothing
in the body. What the box does with the answer:
| Answer | The box |
|---|---|
206 whose Content-Range begins at the size of the part |
appends the rest |
200 whose Content-Range begins at the size of the part |
appends what follows: nothing for a complete part; the rest from a library before 0.45, which answered every range with 200 |
any other 200 |
takes it for the whole file, which replaces the part |
a 206 from another place |
refuses it |
A box before 0.45 took every 200 for the whole file and fetched a broken-off file again from its start. The app
keeps the same rule (app.md, section 6).
The folder of an album (album-folder) is a path below the albums folder, written as it is stored (a/b): not
absolute, no .., no backslash, no part that begins with . (the uploads and the albums on their way) or @ (the
@eaDir of the NAS). The library answers a change of a box with any other one rejected (section 4.5) and the first
filling with 400, and neither side reads, writes or deletes a file for an album whose folder breaks this rule –
what a box or the library delivers is never used as a path before it passed this (sync.manifest.valid_folder, and
inside for the real path).
From a box to the library – an album uploaded to a box, with or without a connection. The essential requirement is that the whole album arrives:
- On the box: the manifest is created only when the upload to the box has been finished (the existing step
POST /api/uploads/{id}/finish). - On the way: every file is verified against its checksum. After an interruption the box asks what is missing and sends only that; no list of the progress is kept.
- In the library: the files collect in a staging folder. Only when every file of the manifest is there and verified does the album move into the library, become visible to other boxes and get its final id.
Until then:
- The box does not delete the files – they are the only copy. An album with a provisional id is never a candidate for freeing space on the SD card.
- Both web pages show the state, e.g. "only on box 1, 7 of 12 files transferred".
- Changes that depend on the album wait in the library. A tag linked to the new album is applied only together with the complete album, so another box never sees a tag that points to half an album. The library stores a waiting change and answers "waiting"; the box keeps it in its outbox until it is applied, so it stays effective on the box.
- The question for the final id (section 4.6) doubles as the question for completeness: as long as the album is incomplete, the answer names the missing files.
Duplicates: it is assumed that no album exists twice. As a by-product the library recognizes an album whose files are identical to an existing one (same checksums): it resolves the provisional id to the existing album, and nothing is uploaded. Two different rips of the same record are not recognized.
- The same album: two albums are the same if their tracks are identical files; playlist and cover do not count. A station is the same if name and address are equal, an entry of Spotify or an advent calendar if name and URI are – also one that a box has created.
- An album whose files cannot be read is an entry of the catalog without files, for which nothing is fetched.
- An album deleted on a box: the library keeps the files; it never deletes audio because a box said so (section 4.5).
The covers of albums, stations and entries of Spotify are files too, in the images folder, and are compared by checksum: a box fetches all of them once, and afterwards only what has changed. The cover of an album is no file of the album and not part of its manifest, so a box has the cover of an album whose files it never fetched.
- Until 0.31 the cover of an album was a file of the album (
cover.jpg,cover.png). The library moves these stored covers into the images folder at its start (Store._take_over_covers()): the album names the image, and the file leaves its manifest in the same step. The album has a new checksum; a box takes the manifest over as any change of the files in the library, removes its own stored cover and fetches no track. No checksum of a track is computed for the move, and the tracks are not read again. - A box before 0.32 does not know that an album names a cover. The library sees the version of a box with every
request and delivers the catalog to such a box as it knows it (
Store.catalog_file()): no album names a cover, andimages.jsonlists the covers of the stations only. A change of an album that such a box sends keeps the cover the album has. Such a box shows no cover of an album until it runs 0.32. - A library before 0.32 refuses the cover of an album that a newer box sends (422). The box notes that once and goes on with the pass; the cover stays on the box.
In the code: AlbumFiles.ready(album, track) says whether a track can be played. If not, STMOperation enters
FETCH_ALBUM and asks for the album; STMPlayAlbum waits in WAIT at a track whose file is missing. Files arrive
under a temporary name and are renamed after their checksum was verified. make_room() deletes the files of
the albums not played for the longest time when less than keep-free is free – only albums whose files the library
has completely.
4.9 Events¶
A box keeps a connection to the library open and receives server-sent events over it – the technique the web page uses for the status already (rest-api.md, section 4).
| Event | The box then |
|---|---|
| catalog changed | compares the checksums and fetches what differs. If the change concerns the tag on the box, it evaluates the tag again (recheck_tag()): a tag linked on the page of the library plays at once |
| new version | fetches the slot in the background (section 6.3) |
| connected | the first event of every connection: the box runs one pass of the synchronization |
- A box that was switched off or without WLAN asks once on its next start and catches up.
- An event carries no content. It only says that something changed; the box then compares as on a start. The event "connected" closes the gap between the last comparison and the moment the box listens: a change made in between would otherwise reach the box only with the next event.
4.10 Versions of formats and of the interface¶
- Every file has a format version. Formats change in the library, forward only.
- The interface between box and library is versioned – a must, because the library and the boxes are updated
independently of each other (section 8). The version is part of every path, e.g.
/api/sync/v3/…. The number is raised only for a change that an older counterpart cannot cope with; something that is merely added does not raise it, so that a new number stays rare. - Version 2 (0.36.0) brought the entries of Spotify into the catalog: a box of version 1 would refuse a
targets.jsonwith one, so it fetches the new version first (section 4.15). - Version 3 (0.49.0) brought the advent calendars: a
targets.jsonwith acalendarand atags.jsonwith a figure ("type": "calendar",day) – a box of version 2 would refuse both, so it fetches the new version first. - The library serves one interface version, its own, and a box speaks one. For a box that speaks an older version the library keeps only what never changes (next point) and answers everything else with an error that says so (section 4.15).
- Five things never change, whatever the version – otherwise a box could neither find out that it does not fit nor get out of it: the initial handshake, the way a box identifies itself with its key, the question "which interface versions do you serve", everything a box needs to fetch a new version (the list of versions with their states, the description of a slot and the slot itself, section 6.14), and the report of a box that cannot start (section 4.22).
- Every write of a box carries the version of the box software that made it.
- The interface of the web page (
/api/…, rest-api.md) is not affected: page and server always come from the same version.
What happens if box and library do not fit together is described in section 4.15.
4.11 Data of a defective version¶
If a version of the box software is found defective (section 6.5), the data it wrote is not trusted either:
- The box rebuilds its working files from the copy.
- What the software wrote by itself – positions, tags it created for unknown UIDs – is discarded as long as it waits
in the outbox: on the box, and in the library, which answers such an entry
discarded. Of what the library applied already, it sets back only the positions, to the state before this version wrote them; the journal lines of this box with this version are flagged as discarded (section 7). - What a person entered with this version on the console or the web page is neither applied nor lost without a trace: it is logged as a WARNING with its full content, like a conflict.
- Defective means that the health check failed (section 6.5). A revert by hand and a version set to
dontusediscard nothing. - Per box and version. The box reports "version X is defective here", and the library discards what this box wrote with this version. The same version on another box is not affected.
- What the library applied already stays, except the positions. Only the outbox still holds entries that are "not applied"; a change that reached the catalog while the version ran – also a tag the software created – is a change like any other.
- To set a position back, the library keeps for every position the value it had before another version wrote it.
4.12 Library restored from a backup¶
A backup is put back on the page of the library or with tinyjukebox-backup --restore (operations.md,
section 10.3). The library stops, swaps its folders and starts again; in that time the boxes cannot reach it and
work alone, and in the moment between the confirmation and the stop they get 503.
- The catalog heals by itself: the checksums differ, the boxes fetch the files.
- Positions and settings: the revision in the library is lower than the one the box is based on, so the box wins.
- What was created after the backup and is confirmed already exists nowhere else and is lost, unless it is still in an outbox.
4.13 Interface¶
The interface for the boxes. The first five never change and have no version in their path (section 4.10); v2 is
the current interface version and follows INTERFACE in sync/__init__.py.
| Method and path | Purpose |
|---|---|
POST /api/sync/boxes |
the initial handshake with the bootstrap password, in two requests; the box receives its own key (section 4.14) |
GET /api/sync/interface |
which interface versions the library serves; the box names itself and the version it speaks |
GET /api/sync/versions |
versions in the archive with their state and the description of their slot (section 6.14) |
GET /api/sync/versions/{version}, …/signature |
a slot, resumable, and its signature |
PUT /api/sync/startup |
why the box cannot start: sent by its start script, also in recovery mode (section 4.22) |
GET /api/sync/v2/catalog |
checksum and format version of every catalog file |
GET /api/sync/v2/catalog/{file} |
one catalog file, whole |
GET /api/sync/v2/boxes/{box}/state |
positions and settings of the box, highest provisional numbers known |
POST /api/sync/v2/boxes/{box}/changes |
entries of the outbox in order; per entry: applied, conflict (with the value of the library), waiting for an album, or rejected |
GET /api/sync/v2/boxes/{box}/ids/{type}/{id} |
the final id of a provisional id, or the files that are still missing |
POST /api/sync/v2/boxes/{box}/journal |
journal lines; the answer says up to which number the library has them |
POST /api/sync/v2/boxes/{box}/diagnostics |
records of its log, in batches with the start of the box; the answer says how many were new (section 4.22) |
GET /api/sync/v2/boxes/{box}/statistics |
what the library counted from the journal of this box (section 7), for the page of the box |
PUT /api/sync/v2/boxes/{box}/status |
with every contact: running version, state of the slots, update mode; the switches of version since the last report; versions the box went back from; versions that are defective here (section 4.11); the data it left out (degraded, section 4.21) |
GET /api/sync/v2/albums/{id}/manifest |
files with size and checksum, playlist |
GET, PUT /api/sync/v2/albums/{id}/files/{file} |
one file: GET resumable (section 4.8), PUT always the whole file |
GET, PUT /api/sync/v2/images/{name} |
the cover of an album, a station or an entry of Spotify |
GET /api/sync/v2/events |
server-sent events (section 4.9) |
What a new box needs before it has anything to register with answers without credentials (section 4.14): the
files of the newest version in the state use that is offered to the boxes and has an installer
(deployment.md, section 5.1).
| Method and path | Purpose |
|---|---|
GET /install.sh |
the installer of a box of that version |
GET /api/sync/install |
the description of its slot (section 6.14) |
GET /api/sync/install/slot, …/signature |
the slot and its signature |
GET /app.apk |
the app for phones of that version (app.md, section 8) |
A request for another interface version is answered with an error that names the versions the library serves.
4.14 Access and authentication¶
How a box reaches the library:
- The library is reached with HTTPS on the port of its web page, under
/api/sync/…, inside the home network. - The box always opens the connection. The library never connects to a box. A box therefore needs no fixed address and no open port for this, and the events (section 4.9) travel over a connection that the box keeps open.
- With every request the box names its version (
X-Box-Version), the interface version it speaks (X-Box-Interface) and the port of its web page (X-Box-Page; a phone has none). The library keeps them with the address the request came from, so that its tab "Boxes" shows that address as a link to the page of the box. The link is only as good as that address: behind a router or a proxy between box and library it is theirs. - The address of the library is a value of the box (section 6.1): set by the installer, and editable on the web page of the box.
- Outside the home network the library cannot be reached, and the box works alone (principle 1). Access from the internet is not planned.
How a box proves who it is:
No part of the interface answers without credentials, except what a new box needs before it has anything to register
with: the installer, and the slot of the version offered for an installation with its signature and description
(/install.sh, /api/sync/install…, section 4.13) – and the app for phones of that version (/app.apk). They are
the files of a release, which are public, and the box verifies the slot by its signature. There are two kinds of
credentials: a bootstrap password for the first contact, and after that keys of this one box.
- The bootstrap password is given to the installer – asked for, or from the vault of the playbook – which writes it to the box, into a file that only the service user can read; the library has the same password from its config or from its tab "Boxes" (below).
- Initial handshake. The box contacts the library and both prove to each other that they know the bootstrap password. Nobody without the password gets an answer.
- Keys of the box. At the end of the handshake the box has a key of its own, which no other box has, and it knows the certificate of the library. The library has stored the key of the box under its internal id.
-
From then on the box uses only its own key. The bootstrap password is not needed by this box any more.
-
The key of a box is valid for the interface of the boxes and for the data of its own box only. It does not open the web page, and it does not reach the positions of another box.
- A box is removed on the page of the library; its key is invalid from then on, e.g. after an SD card was lost. The other boxes are not affected.
- Where the library has the bootstrap password from: its config names it (
bootstrap-password) – on the NAS the installation dialog writes it intovar/library.yaml, in Docker and with--datait comes fromTINYJUKEBOX_BOOTSTRAP_PASSWORDor the file thatTINYJUKEBOX_BOOTSTRAP_PASSWORD_FILEnames. The library stores it insettings.jsonat its first start, and again whenever the config names another bootstrap password than at the start before: it then replaces the stored one. One set in the tab "Boxes" stays until the config names another one. Data of a version before 0.45 does not say which password came from the config: at the first start with 0.45 the library keeps the one it has, and from then on a new one of the config counts. - The bootstrap password can be changed in the vault and in the library at any time; boxes that are registered already keep working with their own keys.
How a box knows that it talks to the library:
The library has a self-signed certificate, like the web page of a box. A program that only pretends to be the library could hand a box a catalog – and software, because a box installs the versions the library offers. The box therefore checks the certificate:
- In the handshake the library proves that it knows the bootstrap password and which certificate is its own. A program in between would have to show another certificate, and the proof would fail.
- The box stores this certificate and accepts only this one from then on.
- The key of the library lies in the folder that survives updates of the package (section 3.2). If the library gets a new certificate nevertheless, the boxes refuse it and work alone. A box deleted its bootstrap password after its handshake; it repeats the handshake only when it is given address and bootstrap password again – on its page, tab "System", section "Library".
The handshake in detail:
- The bootstrap password is generated, not made up: a long random string in the vault file. The installer writes it to the box; the library gets it from its config, on the NAS from the installation dialog (above).
- The password itself is never sent. The handshake is two requests. With the first, box and library exchange a random number each. With the second, the box sends its proof and receives the proof of the library and its key. A proof is an HMAC-SHA256 with the password as key over the role (box or library), the internal id of the box, both random numbers and the fingerprint of the certificate – the one the box sees, the one the library presents. Both proofs cover the certificate; this is how the box checks it at the first contact. Without a valid proof the library answers with nothing but its random number.
- The keys of the box are a long random secret of its own, which the library creates at the end of the handshake and which the box sends with every later request inside the encrypted connection, and the certificate of the library. The library stores only the SHA-256 of the secret.
- The box accepts exactly the stored certificate, not a certificate authority and not a host name: the Python
side compares the certificate of the connection, the shell script uses
curl --pinnedpubkeywith its public key. - They are stored where no new version of the box software overwrites them: in the folder
identity/outside the slots (section 6.1). - The box deletes the bootstrap password after a successful handshake. The installer writes it again only to a box that has no key yet, so that a stolen SD card does not give the password away.
- No click in the library. A box that proves the bootstrap password is registered at once and then appears on the page of the library.
- A phone proves a one-time password in place of the bootstrap password: an admin shows it as a QR code on the page,
and the app is registered as a device of the kind
app(app.md, section 5). The bootstrap password is never on a phone. - Everything above works with what Python brings along; a pair of a private and a public key per box would need a cryptography library.
Scripts on the PC such as check_release.sh (section 8) use a token for scripts, which an admin creates on the page
of the library. It is kept in a file outside git, like the vault file of the playbook. They send it, and the password
of DSM, only to the public key they accepted the first time (scripts/trust.sh, section 8.2).
4.15 Box and library that do not fit together¶
Because the library is updated by hand and the boxes update themselves, a box may meet a library that does not serve the interface version the box speaks. The two directions are treated differently, because only one of them can be repaired by the box.
| The library is too old for the box | The box is too old for the library | |
|---|---|---|
| How it comes about | a box was updated from a file; the library was replaced by an older one. Rare, because the library offers a box only versions whose interface it serves (section 8) | the library got a new interface with an update by hand – the normal course of such a change; or a box was away for a long time |
| The library | answers the question for its versions and shows the box on its page | answers nothing but what updates the box: the question for its versions, the list of versions, the description of a slot, the slot. Everything else is refused |
| The box | stops working and shows a pattern on the LED that is meant to be unpleasant | works as if there were no library: it plays what it has, and collects its changes and its journal |
| Who repairs it | a person; the box cannot | the box: it fetches the newest version, and the ordinary update takes its course (section 6.3) |
The box is too old for the library:
- How both sides notice: with every contact the box asks the library which interface versions it serves (
GET /api/sync/interface) and names itself and its own version. The library shows a box that is too old on its page. - The update is the ordinary one. The box fetches the newest version it may install (section 6.2), switches on its next start, and the version is on probation like any other. With the new version box and library fit again, and what the box collected in the meantime is delivered.
- The next version reads what the old one left. A version of the box software reads the outbox and the journal of every older version back to the fallback version.
- A box in the update mode "manual" does not update by itself here either. It stays too old and keeps working without the library until it is updated by hand; both pages say so.
- A box whose slot script is too old for the new slot cannot install it (section 6.14). It also keeps working without the library, and the page of the library says that the box needs its installer run again.
- No pattern on the LED: for the listener nothing is wrong.
The library is too old for the box:
- What the box does: it logs an ERROR, writes the state to
state/and shows the pattern. - The LED pattern: red, white, magenta, yellow in turn, jumping instead of flowing – with every step two of the three lights of the LED change. Only alarm colors, no color of normal operation. Not faster than three changes per second: the box stands in a child's room, and a pattern must not flash like a strobe.
-
Who can repair it: a person. Either the library gets a package that serves the version of the box, or the box gets another version – with a revert, an update by hand or the fallback version (sections 6.6 to 6.10). The library shows which box speaks which version: "box Kinderzimmer speaks version 3 of the interface, this library version 2" – a library serves one version.
-
"Stops working" means that the box plays nothing, also no album that is on the box already. A running playback ends; tags and buttons are ignored. The web page and the console of the box and
tinyjukebox-slotkeep working, so that the reason can be read there and the box can be given another version. - It is no defect of the version. The box does not go back by itself, and no version becomes
manualbecause of it: the version is fine, it only does not fit this library. - The state stays, also after switching off and on and without a connection. It ends when a later contact shows that box and library fit again. The box repairs nothing; it only notices that it has been repaired.
4.16 Space on the SD card¶
The box keeps a configurable amount of space free on the SD card. When less is free, it deletes the files of albums by itself: the album that has not been played for the longest time goes first, then the next one, until enough is free.
- Every file of the manifest of the album goes, the playlist too. The album stays in the catalog and is fetched again when its tag is placed (section 4.8).
- Never deleted: the album that is playing, and an album that exists only on this box – one whose upload to the library is not complete (section 4.8).
- "Not played for the longest time" follows the order of the journal, not the clock.
- An album that was removed in the library is removed from the box as well.
- By default 500 MB stay free (
keep-free).
4.17 One pass of the synchronization¶
A pass (Link._pass) runs on start, on an event of the library, after a change, every retry-seconds while
something is pending, and otherwise at least every 60 seconds (IDLE_SECONDS):
- ask which interface versions the library serves; handshake if the box has no key
- check that box and library fit together (section 4.15) – if they do not, the pass ends here, for a box that is too old after it looked for versions; otherwise look for versions once after the connection came back and after the event "new version" (section 4.9), and drop what a defective version left in the outbox (section 4.11)
- send the outbox in batches of 20; a new album gets its manifest only now
- compare the checksums of the copy and fetch what differs
- drop the answered entries from the outbox – only now, so nothing disappears between answer and copy
- ask for the final id of every provisional id; send the files the library lacks; fetch and send the covers
- deliver the journal and the records of the log (section 4.22); report the status
4.18 Broken files¶
Broken files are collected by the library, per album and file, from two sources:
- The library itself reads every track of an album (
Store._check(), with mutagen as the album dialog does) as soon as it has all files of the album, and again when its files change. It does so once per state of the files – the checksum of the album – so a start reads nothing again. A track that is no valid mp3 file is noted. - A box whose decoder logs errors while it plays a track (
AudioPipeline.decoder_errors) writes the linebrokeninto its journal when the track has ended – album, file, the end of what the decoder said – and goes on with the next track. ffmpeg plays on through the damaged parts of a file and ends without an error; what it logs is the only sign, and for a sound file it logs nothing (tried with 215 files of the box, from the start and from positions in the middle). The library notes the report with the box and the time when the line arrives (Store.reported()). An output that fails is no broken file and is not reported.
What is noted belongs to the file with its checksum: it is gone as soon as the file is replaced. The album list of the page marks an album with broken files ("broken"). The album dialog names what boxes reported, and an admin drops these reports there ("Discard reports") – for a failure that says nothing about the file. What the library found itself is among the problems of the album, as on a box. Not noticed by either: a file that ends too early at the border of a frame, as after a copy that was cut off.
4.19 Backups¶
What a backup holds, the ways to make one and how it is put back is the same for a box and the library and is
described in operations.md, section 10. This section is about what only the library does: it makes
backups by itself and keeps them in a folder (tinyjukebox/backups.py, which a box without a library uses as
well).
- The data of a backup is taken at one moment, under the lock of the store (
Store.backup_data()): the files of the data folder withoutsettings.json,images-seen.json, the generatedjson/andarchive/;versions.jsonwith the entry of every version saying that its files are to be fetched again; the journal as a copy made by SQLite. The covers and the albums are read afterwards, while the archive is written – an album that changes meanwhile is in the backup as it was read. - Two schedules (
backups.json):plainfor the backups without the albums andalbumsfor those with them. Each is on or off and says every how many days, weeks or months, at what time of day, how many backups of its kind are kept and into which folder they go. The one schedule thatbackups.jsonheld until 0.33 becomes the schedule of its kind when the file is read (_loaded()). - The folders:
backups-folderof the config, by defaultbackupsbeside the albums folder – on the NAS in the shared foldertinyjukebox, so that the backup of the NAS has it. An admin sets another folder for a schedule on the page or withtinyjukebox-backup --library-folder/--library-folder-albums: an absolute path that the library can create and write to. An empty one is the folder of the config again. Backups that lie in a folder that is no longer set stay there. - One thread waits for the next time that is due –
everydays, weeks or months after the day of the last scheduled backup of that schedule, at the time set, never at once for a time that passed while the library did not run (next_due()). It looks at the clock at least once an hour. What is due at the same moment is made one after the other, the backup without the albums first; the second one is not put off for the time the first one took. - A backup is written under a temporary name and gets its name when it is complete; one that fails leaves no
file, and
backups.jsonkeeps when and why, per schedule. After a backup the oldest files of its kind beyond the number to keep are removed. Only files with the name of a backup of the library are listed, sent or removed (tinyjukebox.backup.NAME): other files in the folder are never touched, and a name from a request never becomes a path. - A backup asked for at once ("Back up now",
POST /api/library/backups) is made by the same thread and goes into the folder of its kind; a second one is refused while one is being made, and so is a change of a folder. - A box has the same object (
BoxControl.backups,/api/backups) withkind="box": it lists and removes only backups of a box. It isactiveonly while the box works without a library. - After the data was put back the library is the library it was at the time of the backup
(section 4.12 says what happens to later changes). The entries of the box versions have no files: the slot of the
package is taken into the archive again at the start (
Library._own_slot()), another version is fetched when a box asks for it.
Tests: tests/unit/test_backup.py (the archive, its end), tests/sync/test_backup.py (what a backup of the
library holds, that the data put back is the library again and a box goes on, the two schedules, their folders, a
box without a library, the command),
tests/unit/test_web.py and tests/unit/test_cli.py (a box).
4.20 Unlinking a box¶
A box leaves its library on its own page: tab "System", section "Library", "Unlink from the library", for admins.
The same on the console (unlink_library YES) and as a request (DELETE /api/box/library).
| Kept | everything on the card of the box: tags, the albums that are on the card, stations, positions, settings; the id of the box; its journal, shortened as on every box without a library |
| Removed | the address of the library, the key of the box and the certificate of the library, the copy of what the library delivered, the outbox |
| Lost | changes the library has not confirmed stay on the box and never reach the library; an album that is not completely on the card cannot be fetched any more |
| The library | is not asked. It keeps listing the box until an admin removes it there |
- Unlinking never needs the library. It may be switched off, or not exist any more.
- The page asks first and says what is lost: how many changes the library has not taken, and how many albums
are not completely on the card (
outboxandabsentofGET /api/box). The admin decides: unlink now, or cancel and wait until the box has delivered and fetched. - It takes effect at once, without a restart: nothing is recorded for a library any more, a new target gets a number again, the tag on the box is evaluated again, and the box looks for versions at the releases (section 6.16). From then on the json files of the box are the truth, as on a box that never had a library (section 4.2). They need no conversion: a box with a library writes them with every change.
- A way out of a library that is too old (section 4.15): a box that stopped working because of it plays again as soon as it is unlinked.
- Linking again is a first contact: address and bootstrap password, effective from the next start, and everything the box has is offered to the library, which recognizes the albums and stations it has already (section 4.2). The box keeps its id, so the library shows it as the box it was.
- A target that the library never confirmed keeps its provisional id (
b3f2a1c9-7) on the unlinked box. Nothing depends on the form of an id there.
Tests: tests/sync/test_unlink.py.
4.21 Damaged and unknown data¶
A box is switched off by cutting its power (box.md, section 11), and after a failed update it runs an older version against data that a newer version wrote (section 6.5). Neither may keep the box from starting: a damaged or unknown piece of data costs that piece, not the box. The box works with what it can read and says what it left out.
- The json files of the box (tags, targets, positions) are checked entry by entry, against their schema and by the class that loads them. An entry that is damaged, or of a kind this version does not know (e.g. a target type that came later), is set aside: the box works without it, keeps it in the file and writes it back unchanged with every write. An old version never deletes what a newer one wrote.
- A key that is set aside stays taken: a tag set aside is never created again as a new tag – placed on the box it is an unknown album, and the page refuses to create it – and the id of a target set aside is not given again.
- A field this version does not know is kept with its entry and written back, also in a change that goes to the library: changing the name of a station does not delete a field that a newer version added to it.
- With a library the json files are made from the copy on every start and every change (section 4.2), entry by entry in the same way: an entry the library no longer has is no longer kept either.
- If neither a json file nor its backup can be read, both are kept as
<file>.damaged-<time>for a person to repair, and the box starts without the entries of that file. Nothing is overwritten. Without a library the backup of the box is the way back (operations.md, section 10); with a library the file comes back from the copy. - A file of the copy that cannot be read counts as missing: its checksum differs from the one of the library, which delivers it again.
- The outbox keeps its complete lines. A damaged line – a change of this box – is lost; the box says so.
- The journal keeps its complete lines, and every lost line becomes a mark (section 7).
- The box says what it left out: a WARNING or ERROR in the log, the list "Data left out" in the tab "System" of
its page, and the field
degradedof its report to the library (section 4.13), which the tab "Boxes" of the library shows as a note. - Strict where it matters: what the box uses is valid. Tolerance means leaving out what does not fit, never guessing a value.
What an old version guarantees is limited to this: it starts and plays the albums and stations it can read, and it
can update itself (section 6.10). It does not play a target type that came later, and it does not synchronize a field
it does not know beyond keeping it. A version before 0.40.0 cannot do even that: 0.30.0, for example, refuses a whole
targets.json with one cover field.
Rule for a new format. A change that an older version cannot read safely – not a new field, not a new kind of entry, but e.g. a field whose meaning changes – is written in a form the older version understands until the new version is confirmed on the box. Going back to the version before must never become a restart loop.
Tests: tests/unit/test_tolerant_data.py, tests/unit/test_persistence.py, tests/sync/test_recovery.py.
4.22 Central diagnostics¶
The log of a box lives on its card: a file that rotates, and the newest 5,000 lines in memory for its page. When a box is down, or what mattered has rotated away, the library keeps what is needed to find the fault – without having the box in front of you. It is a technical log, kept apart from the journal (section 7), and only admins see it.
What a box sends. tinyjukebox.diagnostics.Collector, a log handler of the application:
- every record from WARNING up, and every record that a component marks as a structured event
(
extra=diagnostic(code, …)), whatever its level – e.g. the start of a radio stream; - per record: time, seconds since the start of the box, level, component (the logger below the application), a
stable code – the code of a structured event, otherwise the text of the log call before its values – the message,
safe parameters, exception class and traceback; per batch: the start of the box (
boot) and its version; - redacted before it is kept: passwords, keys, tokens and the
Authorizationheader, certificate material, the query and the user of an address, tag UIDs, the addresses of Bluetooth devices and the ids of Spotify. A structured event can bring a text of its own instead of the message of the log file – the radio names the host of a station, never its address. Titles of albums can appear in a warning; INFO is not sent for that reason. - It never waits. The handler puts the record into a bounded buffer (2,000 records) and returns; the synchronization sends the buffer in its own thread with every pass (section 4.17), an ERROR wakes it at once. Without a connection the records wait. When the buffer is full, the oldest INFO goes first, then WARNING, then ERROR; the box counts what it dropped, and the library notes it as a record of its own. A record that repeats is sent once with its count. A library before 0.41 does not know the request: the box keeps the records, bounded, and asks again after the next break of the connection.
What the library keeps. diagnostics.sqlite in the data folder, apart from the journal. A record is taken once
(box, start, number); a batch sent again changes nothing. Records older than 30 days go, and per box more than
20,000 – diagnostics: {days, records-per-box} in the config of the library. A backup holds a copy of the file as it
is at that moment, not a copy made by SQLite as of the journal (section 4.19).
A box that cannot start sends nothing of this: its application does not get as far as the synchronization.
- The application leaves why it ends with an error with the slot script (
tinyjukebox-slot _error TEXT, its redacted traceback), the start script counts the failed starts instate/failures(the application ended with an error or hung), and before the next start it sends a short report: version, slot, number of failed starts and since when, the last error (tinyjukebox-slot _report-startup, at most some seconds). In recovery mode (section 6.10) the report also says why the box is there, with every attempt. - The request belongs to what never changes (section 4.10):
PUT /api/sync/startup, so also the old version in the fallback slot reaches the library with it. Each report replaces the one before: a restart loop does not fill the library. - The tab "Boxes" shows such a box with "cannot start since …", the tab "Log" the report with the error. As soon as
its application runs again and reports (section 4.13), the report becomes a record
startup.failedof its history; stage 1 of the health check clears the count on the box.
The page. The tab "Log" of the library chooses whose log: the library's own, live as before, or that of a box,
with filters for level, component, version, code, time and text, the report of a box that cannot start at the top.
The shown records can be copied; "Save as file" saves up to 20,000 matching records as lines of JSON. The requests:
GET /api/library/diagnostics and …/export (rest-api.md).
The start of a radio stream is measured per connection on a clock that does not jump: the playlist, the start of
ffmpeg and aplay, the first audio from ffmpeg (-progress), and when it is heard: aplay plays once its buffer is full
(buffer-time, 2000 ms on the box, aplay's own default 500 ms), so the stream is heard once ffmpeg has delivered that
much audio. Only from then on is the station "playing" and the LED blue; before, it flows green, aqua, blue. aplay says
when its buffer ran empty (underrun, also with -q, in the language of the box – Unterlauf!!! (mindestens 2844,463
ms) on a German one): such breaks are counted, those of the first 30 s apart. When the first 30 s are over, or the
connection ends before, the box logs radio.startup with all of it; a connection that ends logs radio.retry with the
part that failed – playlist, start, connection, decoder, output (sound card, headset) or ended – how long it
played and its breaks. What these numbers show on real stations decides how the start of the radio is made faster
(roadmap.md, section 2).
Never a command channel. The records go from the box to the library only, and the library answers nothing but how many it took. Playback never waits for any of it: a library that is offline or full costs records, not sound.
Tests: tests/unit/test_diagnostics.py, tests/sync/test_diagnostics.py, tests/unit/test_radio.py.
5. The box with a tag¶
| Situation | What the box does |
|---|---|
| Unknown tag | it looks into copy and outbox, then compares the catalog with the library. If the tag is still unknown or the library cannot be reached, it creates the tag as "not linked" (LED yellow) and reports the UID to the library, where it appears for linking |
| Tag of a station the box does not know | the station is only a name and a URL; it arrives with the catalog, and the box plays at once |
| Tag of an album whose files are missing | STMOperation enters FETCH_ALBUM; the box fetches and plays as in section 4.8 |
| Next track has not arrived | STMPlayAlbum waits in WAIT at the end of the track |
| Catalog changed while a tag lies on the box | recheck_tag(), the entry point that linking on the web page of the box uses, triggered by the event of the library |
LED: while the playback is halted until a file is there – before the first track and at the end of a track – the LED shows a sequence of three colors: green, aqua, blue, aqua, green and so on, about 0.4 seconds each. The three are neighbours, so only one of the three lights of the LED changes with every step and the colors seem to flow back and forth instead of flickering; they are the colors of "waiting for a tag", "starting" and "playing", and none of the colors that mean a problem (yellow, red).
Editing on the web page or the console of a box stays possible in full. Every change goes through the outbox.
A new tag reaches the catalog in three ways: a box reports the tag that was placed on it, an admin enters the UID on the page, or a phone reads the tag on the page of the library (section 5.1). In all three the tag is not linked at first, and the boxes receive it with their next pass.
5.1 The phone as reader¶
A phone that is logged in to the page of the library reads a tag with its NFC chip, and the library adds it to its catalog. A new tag gets into the system without a box, and a tag can be looked up without stopping a box that plays. It needs no hardware and no program besides the browser. Tried in a browser with a stand-in for the reader of a phone, and a tag was read with a real phone (roadmap.md, section 1).
How a browser reads a tag. The page uses Web NFC (NDEFReader), which the browser brings along:
- Chrome on Android only. Not on an iPhone, not in Firefox. On a PC the page offers the scan only where
NDEFReaderexists. On a phone the button is always there, and whereNDEFReaderis missing a tap says why: an iPhone, another browser than Chrome, a page without HTTPS, or Chrome that does not offer NFC to this page (with its version). - HTTPS only. The self-signed certificate of the library is enough: once its warning is accepted by hand,
Chrome offers
NDEFReaderto the page and asks for NFC (tried with Chrome 113 in an Android emulator). - A scan starts with a tap. Chrome asks once whether this page may use NFC. If Chrome refuses
(
NotAllowedError), the page says below the button what to do, and the text stays: the page is blocked – by an answer to that question or for all pages – and is allowed again in the settings of Chrome for this page; or the question was only closed (navigator.permissionstells which), and the next tap asks again. - It reads only while the page is visible and the phone is unlocked.
- Every touch gives one event with the UID of the tag (
serialNumber, e.g.04:1a:2b:3c:4d:5e:6f). The page turns it into the form ofrfid-reader, upper case:04:1A:2B:3C:4D:5E:6F. - No presence. The browser reports the touch. It does not tell how long the tag stays or when it is taken off,
as
rfid-readerdoes on a box. - A tag is reported only if Android can treat it as an NDEF tag, formatted or not. Tags with a UID of 7 bytes are
the kind phones read; the tags of our box all have one, from two makers (UIDs that begin
with
04and with1D). - Tags are read only: only the UID counts, and nothing is ever written to a tag.
What a scan does. It makes sure that the library knows the tag, and nothing more:
| The UID is | The library |
|---|---|
| unknown | creates the tag, not linked – the tag a box creates when an unknown tag is placed on it. Changed by web:<user> |
| known | changes nothing |
- In both cases the answer is the tag, and the phone shows it. The library keeps nothing about the scan: there is no "scanned tag", nothing that lasts and nothing that expires.
- Tags are global: one catalog for all boxes and all users. The new tag is in it like every other tag, and the boxes receive it with their next pass, which the event of the library starts at once.
- The request is
PUT /api/tags/{uid}(LibraryControl.scan_tag()), on the library only:201if the tag was created,200if it was known (rest-api.md, section 3). It needs the roleuseronly; naming and linking stay withadmin. Because every user can create tags with it, it takes nothing but a UID in the form of the reader: 4, 7 or 10 bytes, two hex digits each, upper case, separated by colons.
The page, tab "Tag management" of the library on the phone (web/web/app.js):
- The card "Create a new tag" has the button "Scan tag", next to the fields that create a tag by hand. A user, who cannot create a tag by hand, sees the card with the button only. While it scans, the page says so: "Hold a tag to the back of the phone".
- After a touch the page shows the tag in that card: cover, name, UID and what it is linked to, or "not linked". The table of the tags above has the new tag at once.
- An admin finds at the tag what its row offers: name, album or station. For a user the tag stays not linked until an admin links it, on any device.
- The scan goes on; the next tag replaces the one shown. "Stop scanning" or another tab ends it.
- No other page reacts to a scan. No message, no change of tab, no list that loads again – neither on the pages of the library nor on the pages of the boxes. On another device the new tag is in "Tag management" the next time the tab is opened, exactly as a tag that a box reported.
| Left out | Reason |
|---|---|
| Presence of a tag | the browser does not report it, and adding a tag does not need it |
Placing a tag on a box by a scan, also on the box on the PC (--no-hardware) |
the purpose is to add a tag. On the box on the PC tags are clicked; the library never connects to a box (section 4.14) |
| The button on the page of a box | the library is where the tags of all boxes are managed |
| An iPhone as reader | its browsers have no Web NFC |
| An app for managing the library | the browser is enough; the app for listening: app.md |
Tests: tests/sync/test_library_and_box.py (an unknown tag is added and reaches a box, a known one stays as it is,
role user, UIDs that no reader reports) and tests/unit/test_web.py (a box does not have the request). The page
was tried by hand in Chromium on the PC with a stand-in for NDEFReader: admin and user, a new and a known tag,
name, link, a tag that cannot be read, a refused scan, another tab.
5.2 The app¶
A phone or tablet with the app for Android is a box of its own kind: a tag on the phone plays its album, and the app shows the page of the library as well. It registers with a one-time password from the page and uses the interface of the boxes with what section 4.14 allows a phone. Everything about it: app.md.
6. Self-update of the box¶
The box fetches new versions in the background and behaves like a router with two firmware images: a new version is tried on the next start and stays if it works; otherwise the previous one runs again and the problem is reported to the library.
6.1 Slots¶
/srv/tinyjukebox/
├── slots/
│ ├── a/ VERSION, Python with tinyjukebox, rfid-reader, config, checksums (section 6.14)
│ ├── b/
│ ├── fallback/ the first version of the box; never replaced automatically (section 6.10)
│ └── next/ a version that is installed and waits for the next start (section 6.3)
├── current -> slots/b the active slot, a relative link; the start script always starts current/…
├── identity/ internal id, own key, address and certificate of the library (section 4.14)
├── slot.conf values of this box for the scripts: platform, level of the setup, time limit of stage 1,
│ address of the releases and their waiting time (section 6.16)
└── state/
├── slots per slot: version, state, since when, number of starts; the newest version this box has run
├── versions states of the versions: copy of the library, and own changes not reported yet
├── versions.json what can be installed: as the library listed it, or the newest release (section 6.16)
├── update mode: automatic, manual or off
├── history.log one line per switch: when, from, to, why, by whom
├── refused archives this box refused; they are not fetched again by themselves while the key of the
│ releases stays the same (section 6.14)
├── skipped a release an admin skipped on a box that asks (section 6.16)
└── … notes of the scripts: a switch in progress, a problem, what is still to be reported
Everything is plain text; ls -l and cat show what runs and why. The link current is the only truth about
which slot runs; state/slots only describes the slots.
Only the scripts write state/. The application reads it with tinyjukebox-slot status and reports through
internal commands of the script: stage passed, reason of a failure, whether the library fits, what the library has
received. A lock makes tinyjukebox-slot and the start script take turns.
A note in state/ is replaced in one step: the scripts write it to a new file and rename that, so a power cut
leaves the old note or the new one, never half of one. The notes a switch depends on (trigger, switch, the link
current) are on the card before the switch goes on. What the scripts read there they check first: a note that is
empty or damaged – left by a script before this rule – counts as no note. An empty state/update is the mode
"automatic", an empty note of a switch means that the switch is made from the start.
| Part | Where | Changed by |
|---|---|---|
| Code with its own Python, config | in the slot | an update |
| What identifies this box: internal id, own key, address and certificate of the library | the folder identity/, outside the slots |
the handshake (section 4.14) |
| Update mode | state/update |
the admin |
rfid-reader |
in the slot. It runs as root: a rule of the installer allows a launcher of root, which starts the program of the active slot only if the release signed it or the installer listed it (box.md, section 7) | an update |
Start script and tinyjukebox-slot |
outside the slots | the installer, or a deliberate step |
Operating system packages, /etc/asound.conf, Bluetooth setup, service unit |
system | the installer |
| Copy, outbox, journal, albums | /srv/tinyjukebox |
the box |
- A slot arrives built. Building a virtual environment on the Pi 3 takes minutes, loads the CPU and may disturb playback. The pipeline builds the complete slot for aarch64; the box only downloads and unpacks.
- Unpacking survives a power cut: into a temporary folder first, then renamed to
slots/nextand entered instate/slots. A slot without a line there is never started. identity/is not overwritten by a new version of the box software. No update, revert or fallback touches it. The installer – also when the playbook runs it – writes onlyidentity/library,bootstrap(to a box without a key) andreleases-token, and removes the token with--no-token; never the id, the key or the certificate of the library. All three slots read it, the fallback version too: its files are plain text with fixed names, and their format changes only as carefully as the interface (section 4.10). Only the service user can read it.- A slot states what it needs from the system. If a version needs a change that only the installer makes, the box does not install it and reports this.
- A slot brings a list of the checksums of its files. It shows whether a slot was changed by hand (section 6.12).
Not a real A/B system: that would mean two system partitions and a boot loader that switches, too much for a box.
6.2 Which version runs¶
Every version of the box software has one of three states. The states are the same for all boxes and are kept in the library; every box holds a copy.
| State | A box in the mode "automatic" | A box that runs this version | Installing by hand |
|---|---|---|---|
use |
installs it, if it is newer than every version the box has run so far | keeps it | possible |
manual |
does not install it | keeps it | possible |
dontuse |
does not install it | leaves it at once | only after an explicit confirmation |
Who sets a state:
- A new release starts as
use. - As soon as one box goes back from a version, the version is
manual– for all boxes. It makes no difference whether the health check sent the box back or a person did. The version is no longer rolled out by itself; boxes that run it keep it. - In the library every state can be set by hand, on a page with all versions. Setting a version to
dontuseis the revert for all boxes: every box that runs it leaves it. - Who set a state, when and why is recorded and shown.
A box that cannot reach the library applies the change to its own copy of the states and reports it with its next contact, in its status report and with the revision it is based on (section 4.13). If the library set the same version differently in the meantime, the library wins (section 4.5).
What a box in the mode "automatic" does:
- There is a version in the state
usethat is newer than every version it has run so far: it fetches it and tries it on the next start (section 6.3). A box therefore never moves forward by itself to a version it has left before; such a version is installed by hand. - The version it runs becomes
manual: nothing. - The version it runs becomes
dontuse: it switches at once to the newest version in the stateuse– from its other slot if that slot holds it, otherwise from the library.
The update mode of a box says whether it installs anything by itself:
| Mode | The box |
|---|---|
| automatic | as described above |
| manual | installs nothing by itself and only shows that a version is available; the update is started by hand (section 6.7). It still leaves a version that is set to dontuse in the library, because that is the revert for all boxes |
| off | does not look for versions at all and asks nobody. It therefore does not learn of a version set to dontuse either: off is off. An update by hand stays possible |
A box without a library has the same three modes; what they mean for it is in section 6.16.
6.3 Life cycle of a version¶
stateDiagram-v2
[*] --> downloaded: fetched, verified, unpacked
downloaded --> probation: next start
probation --> confirmed: stage 1 and stage 2 passed
probation --> abandoned: stage 1 failed, or unexpected exception
confirmed --> abandoned: revert by hand, or dontuse in the library
probation --> [*]: replaced by a newer release
confirmed --> [*]: replaced when a newer version is confirmed and the next one arrives
abandoned --> [*]: the slot is reused by the next version
- The diagram shows a version in a slot of one box. "Abandoned" means that the box went back from it; its state is
manualordontusefrom then on (section 6.2). - A new version is installed to
slots/nextand moved into its slot by the next start, before anything runs. Its slot is the one that does not hold the last confirmed version. This is how a version on probation is replaced, although its slot is the active one. The start script notes the move before it begins and completes it after a power cut. - A newer release that arrives during the probation replaces the unconfirmed version, not the confirmed one.
- A newer release that arrives while a version still waits in
slots/nextreplaces the waiting one: the box never starts the version in between. - An automatic update never switches while the box runs; it waits for the next start.
- A version that the box installed by itself is marked as such. If it stops being
usebefore the box has switched, the box drops it instead of switching.
6.4 Health check¶
| Stage | When | Passed if |
|---|---|---|
| 1 | seconds after the start | the software runs; everything the config asks for runs (drivers, rfid-reader, web page); no unexpected exception |
| 2 | in use | a tag was recognized, an album was played because of it, and a configured time passed after that without an unexpected exception |
- Unexpected exception needs a boundary. A broken album, a dead station or a library that cannot be reached are expected errors and do not count. Counted is what arrives at the central places: the threads, the state machines, the dispatch of the messages, a request of the web page that fails with an internal error, the synchronization (section 6.15). A version whose page fails that way while it is on probation goes back as well.
- Album and station separately: an album that played confirms the version. A station that played is recorded separately; a stream that fails never condemns a version, because the cause is mostly the station or the internet.
- Without RFID: stage 1 checks the drivers that the config names, so a box without a reader has none to check for it. Stage 2 has the same proof on every box: an album that plays because of a tag – on a box without a reader a virtual one placed on its page. A playback that no tag started proves nothing.
- No tag at all: the version waits. It stays on probation however often the box is switched off and on without a tag – it is not known why that happened. The previous version stays in its slot, and the library shows since when and for how many starts a box has been on probation. Going back is then a decision by hand (section 6.6).
- Start without a result: a box that is switched off during stage 1, or an under-voltage, does not condemn a
version: such a start is no failure; the library only shows it among the starts on probation. A version that hangs
is caught by a time limit instead: stage 1 must pass within a fixed time while the box is on. The limit is a value
of the box in
slot.conf, and the start script enforces it (section 6.11). It measures the seconds since the boot (/proc/uptime), not the clock of the system: the Pi has no real-time clock, starts with the time it was switched off at, and its clock jumps forward by those hours as soon as it is set from the network – a few seconds after a new version has started. - Not detectable: a silent speaker. The amplifier gives no feedback, so a version without sound stays "confirmed" – this is what the revert by hand is for.
- After the confirmation an unexpected exception no longer leads back; it is only reported to the library.
- Not checked either: whether the version can still fetch the next update; nothing tests that path. A library that does not serve the interface of the box is no fault of the version and is treated differently (section 4.15).
6.5 Falling back¶
- At once, not on the start after next. If the new version crashes or does not pass stage 1 in time, the start script starts the previous version after a few seconds; the listener notices a short pause at most.
- The box went back from the version, so the version becomes
manual(section 6.2), and the box reports why: the reason of its last switch away from the version, e.g. "health check failed: stage 1 of the health check not passed within 120 s". The library logs it, keeps it with the state of the version (tab "Versions") and records the switch in its journal. On the box it is instate/history.logand intinyjukebox-slot status; the log file of the application does not have it, because the start script ends the version. - The switches are numbered by their line in
state/history.log; box and library count which ones were reported. If the list is shorter than that count – a card set up again with the foldersyncof before – both count anew. - The data is treated as in section 4.11.
- If neither
anorbcan be used – both aredontuse, or neither comes up – the box switches to the fallback slot, which starts recovery mode (section 6.10). If there is none, or if it holds the very version that failed, the box stays on the running version and reports that: a version that plays is better than a box that does not start.
6.6 Revert by hand¶
A revert takes effect at once: the box switches and restarts its software, also while something is playing.
| On a box | In the library | |
|---|---|---|
| Applies to | this box | all boxes |
| How | "Back to the previous version" on the web page, tinyjukebox-slot revert in a shell – the console of the application has no revert –, or one of the ways of section 6.9 |
set the version to dontuse on the page with the versions |
| State of the version afterwards | manual |
dontuse |
| Other boxes | keep the version if they run it; no box installs it by itself any more | leave the version at once |
- To the other slot or to a specific version.
revertand "Back to the previous version" switch to the other slot, whatever it holds, and need no library. That is not always the last working version: after a failed probation the start script went back by itself and left the failed version in its slot (abandoned, section 6.5), and a revert would start it again. So look first withtinyjukebox-slot status. A specific older version comes from the archive of the library, on a box without a library from its release (section 6.16). The versions in between do not come back by themselves, because the box only installs what is newer than everything it has run (section 6.2). - The next release is automatic again: it starts as
use, so a box in the mode "automatic" fetches it. A box in the mode "manual" only shows it. - No probation. A person chose the version. The ordinary start check still applies: if the version does not pass stage 1 within its time limit, the box returns to the slot it came from and reports it.
- A box that is switched off starts in its old slot first – it must not wait for the WLAN – and follows as soon as it learns of the change.
- Every revert is detected and stored, whatever the path. On each start the start script compares where
currentpoints with what it noted last. If the link was changed without an entry – withlnin a shell, or on a mounted card – it writes the entry itself ("by hand, source unknown") and sets the newer version that was left tomanual; otherwise the box would switch back on the next start. The box reports the revert to the library with its next contact, and the library stores and shows it per box. - Only admins may revert. Every revert is written to the journal: who, when, from which version to which, and where it was triggered.
6.7 Update by hand¶
An update can be started on the box at any time – necessary because the automatic update can be switched off.
- The mode stays what it is. A box in the mode "manual" updates once and fetches nothing by itself afterwards.
- The health check applies as for an automatic update.
- A version in the state
manualis installed by hand like any other. A version in the statedontuseis shown, but installed only after an explicit confirmation; the box logs this and reports it. - On the web page of the box the functions are in the tab "System", for admins: running version, state of the slots, mode, "Look for a new version", "Update now", "Back to the previous version". The progress uses the existing status events.
- "Update now" (
update --now) restarts the service with a command.tinyjukebox-slotruns the command thatslot.confnames,sudo systemctl restart tinyjukebox, which a rule of the installer allows the service user without a password. The start script then selects the new slot. Called from the web page, the application answers first and lets the restart happen then, so the page receives its answer. - A change of the mode is reported to the library at once, not with the next ordinary contact: the page of the library says which box updates by itself.
6.8 The command tinyjukebox-slot¶
One shell script, outside the slots. It works when the application does not start at all; it prints every step and
writes every switch to history.log. The automatic fallback, the web page and the shell all go through this script.
| Call | Effect |
|---|---|
tinyjukebox-slot status |
the slots with version and state, the active slot, mode, states of the versions, last switches; "modified" for a slot changed by hand |
tinyjukebox-slot check |
asks the library – a box without one the releases (section 6.16): installed and available versions with their states |
tinyjukebox-slot update [--now] |
the newest permitted version, for the slot that does not hold the last confirmed version – the active slot while its version is on probation (section 6.3); active from the next start, with --now at once |
tinyjukebox-slot update VERSION |
exactly this version; if it is older than the running one, this is a revert (section 6.6) |
tinyjukebox-slot update FILE |
installs from a file, without asking anybody. FILE.sig must lie next to it, its description (.json) should – otherwise the one inside the archive is used; ./fetch_release.sh on the PC fetches all three into dist/ |
tinyjukebox-slot revert |
switches to the other slot at once, whatever it holds (section 6.6), and restarts the service; the version that is left becomes manual if it is the newer one |
tinyjukebox-slot use a\|b |
switches to one slot |
tinyjukebox-slot fallback |
switches to the fallback slot; the versions in a and b become manual |
tinyjukebox-slot promote [a\|b] |
makes the confirmed version of slot a or b the fallback version – the two buttons of the tab "System"; without a slot the newest confirmed version of both (the installer). Not while the fallback version runs |
tinyjukebox-slot mode automatic\|manual\|off |
sets the update mode |
tinyjukebox-slot skip VERSION |
a box without a library in the mode "manual": do not ask for this release any more (section 6.16) |
--root DIR |
works on a mounted card instead of the running system; no restart |
--yes |
answers the questions with yes: overwrite a slot changed by hand, install a version that is dontuse |
--by WHO |
who asks, for history.log; the web page names its user here |
status --json |
the same for the application, which reads state/ only this way (section 6.1) |
- Tools it needs on the box:
bash,curl,jq,openssl,flock. The installer installs them. - Not as root. Everything below
/srv/tinyjukeboxbelongs to the user of the service. Files the scripts wrote as root would belong to root from then on, and the service could not replace them any more: the scripts refuse to run as root – with--rooton a mounted card they may. On the box:sudo -u pi tinyjukebox-slot …. - It talks to the library itself, with the key and the certificate from
identity/, and only through what never changes (section 4.10): the list of versions, a slot and its signature. - Without a library it talks to the git server itself and gets the same three things from the newest release (section 6.16). Everything after the download is the same code.
- Key and token go to
curlthrough a pipe (-H @file), never as arguments: those can be read by every user in the list of processes. - A request that stalls ends: a download that is slower than 1 kB/s for a minute, a small request after two minutes at most. A slot of 14 MB over a slow WLAN still passes; a download that broke off continues with the next attempt. Otherwise a stalled request would hold the lock, and with it every command of the page and the next start.
- Versions are
X.Y.Z.sort -Vwould put0.21.0rc1after0.21.0; the script ignores a version of any other form – in the list of the library, as the tag of a release, as an argument.
By hand, if even the command is missing:
sudo ln -sfn slots/a /srv/tinyjukebox/current
sudo systemctl restart tinyjukebox
6.9 Without network¶
The way back must also work without WLAN and without the library.
- Keyboard and monitor: log in,
tinyjukebox-slot revert. The local console exists although the installer disables the HDMI graphics driver (config.txt, box.md, section 3). - SD card in the PC: change the link by hand, or run the script from the repository with
--root. The link is relative, so it is right wherever the card is mounted. A change by hand is detected on the next start (section 6.6). -
Trigger file on the boot partition. The boot partition is FAT and readable on every computer.
-
Create an empty file
tinyjukebox-revertortinyjukebox-fallbackon the boot partition (bootfson the PC,/boot/firmwareon the box)..txtis accepted too, because Windows adds it without showing it. - On the next start the start script finds the file before the service starts and goes back as
revertorfallbackwould.tinyjukebox-fallbackwins if both files exist. - It writes the switch to
history.log: whyrevertorfallback, by "file on the boot partition". - It deletes the file and writes
tinyjukebox-ergebnis.txtin its place: what happened – from which slot to which, and the version it switched to – or why nothing happened, e.g. "no other slot".
A power cut in the middle must not cause a second revert, because revert refers to the running version. The
script therefore notes the target in state/ first, switches then, and deletes the file last. If it still finds
the file on the next start, it only completes the noted switch.
The boot partition is written only when a trigger file was found. FAT has no journal, and a damaged boot partition means a box that does not start; a status file updated on every start is left out on purpose.
6.10 Fallback slot and recovery mode¶
A third slot for the disaster case. It starts no application: the box is in recovery mode.
| Slot | Job | Starts the application |
|---|---|---|
a, b |
normal operation, probation, going back to the last confirmed version | yes |
fallback |
recovery mode: fetch, verify and install a version for a or b |
no |
- Recovery mode is part of the start script (section 6.11), which lives outside the slots: it reads neither the config file nor the catalog, the outbox or the journal, and it serves no page. An old version in the fallback slot could not read the data of today (section 4.21) – which is why it is not started.
- It asks for versions the way every update does, over what never changes in the interface (section 4.10): the library
of the box, or the releases (section 6.16); the box identifies itself with the version in the fallback slot. From
the versions in the state
useit takes the newest one that it has not gone back from, has not refused, and that suits the box; a release does not wait until itsWAIT_DAYSare over (section 6.16) – a box in recovery plays nothing. The version is installed, the service restarts, and it runs on probation like every new version. If it fails, the box comes back here; with a library it tries the next older one, without one only the newest release is offered (section 6.16), and the box waits for the next release. - If nothing can be installed, the box stays in recovery mode: it plays nothing, says why in
state/problem(tinyjukebox-slot status,journalctl -u tinyjukebox) and asks again every 10 minutes – without starting an application, so without a restart loop. The time isRECOVERY_WAIToftinyjukebox-functions; a value inslot.conflasts only until the next run of the installer, which writes the file anew. An administrator ends it withtinyjukebox-slot update --now VERSIONorFILE, or withtinyjukebox-slot use a|b. Without--nowthe version only waits inslots/next, and in the mode "automatic" the next attempt of recovery mode may put the newest version in the stateusein its place. -
With the update mode
manualoroffrecovery mode installs nothing by itself and waits for the administrator. -
The first version of a box is its fallback version. When a version is installed on a box that has no version yet, its verified files are put into the fallback slot as well, before the version has started once. Nobody has to set it.
- A first version that does not work is no fallback either. That costs nothing: such a box is set up again with the playbook anyway. The start script does not leave a first version that fails for its own copy (section 6.5).
- After that it changes only on purpose:
tinyjukebox-slot promote a|bmakes the confirmed version of a slot the fallback version. An update never touches it. - A box that was set up before version 0.19.0 has an empty fallback slot. The installer fills it with the version the box runs, if that version is confirmed.
- It starts if neither
anorbcan be used (section 6.5), ifcurrentpoints to nothing and neitheranorbholds a confirmed version (section 6.11), or by hand (tinyjukebox-slot fallback, trigger file). - What never changes (section 4.10) is all recovery mode uses: identifying the box and fetching a version. The library keeps these requests open for a box of any interface version.
- To be tested when the interface changes (U13, section 9.5): the slot script must still be able to fetch a new version from the newest library.
Tests: tests/sync/test_recovery_mode.py.
6.11 Start script and updater¶
- Outside the slots, small, pure shell. The script changes only through the installer or a deliberate step, so it must stay small: download, verify the checksum, unpack, switch.
- The script does the "how", the application the "when". The application decides when to update – on the event of the library, on start, on a button – and calls the script. An update over the shell therefore also works when the application does not run, and a version with a defective updater cannot lock the box in.
- It verifies the target before it starts. A typing error by hand may leave
currentpointing nowhere; the box then starts the confirmed version – the newer one, if bothaandbhold one – and logs this instead of not starting. Only a box without a confirmed version goes to the fallback slot, as in section 6.5. - The start script is what systemd starts (
ExecStartof the unit), and the application runs as its child. Only something that outlives the application can stop a version that hangs in stage 1 and send the box back at once. In order, on every start: complete an interrupted switch, look for a trigger file, detect a link changed by hand, move a version fromslots/nextinto its slot, verify the target, start the application and watch it – or, in the fallback slot, run recovery mode (section 6.10). - How the box falls back: the start script notes the failure and the new target and ends with an error; systemd
starts it again (
Restart=on-failure), and it starts the previous version. An application that ends because it was stopped or asked for a restart is no failure. - No
backup_json.sh: the unit of a box with slots does not run it, andjson/backup/is not written any more. With a library the library is the backup; a box without one makes its own backups (operations.md, section 10). - It uses
sudofor the trigger file (section 6.9), because the boot partition belongs to root: a rule of the installer allows exactly the removal of the trigger files and the writing of the result. Without it the file would stay, and every start would obey it again.
6.12 Changes made on the box¶
A box with slots still holds its git repository, because the other roles of the playbook take their files from it, and the playbook never overwrites a fix made there (deployment.md, section 6). The software that runs comes from the slot:
- The update mode "manual" stops automatic updates while someone works on the box.
- A slot changed by hand is recognized by the list of checksums.
statusshows "modified". In a shellupdateasks before it overwrites such a slot; from the web page it does not ask, because the page gives every command--yes. An update by itself never overwrites it. - A fix is brought to GitHub and comes around as a release, like every change. A box that is still in the mode "manual" does not get it by itself.
6.13 What the library does¶
- Archive of box versions. The package brings the slot of its own version; newer versions for the boxes come from
the releases (section 8). With every version the library keeps its installer (
install.shof the release, from 0.38.0 on): the line of the section "Add a box" in the tab "Boxes" installs the newest version in the stateusethat has one. The library keeps older ones or fetches them again from the releases when a box needs them. Two limits can be set, the number of versions and the disk space; the one reached first applies. The number is set on the page, tab "Versions" (five, as long as nothing is set;keep-versionsof the config is the value before that), and counts at once; the disk space iskeep-bytesof the config. Never deleted: versions that run on a box or lie in one of its slots, the fallback version among them, the version of the library itself and the newest version in the stateuse– they count for the limits all the same. Of the others the versions in the statedontusego first, then the oldest. The entry of a version and its state stay. - Overview of the boxes: per box the name, the running version, the state of the slots (on probation: since when and for how many starts), update mode, fallback version, last contact with the address it came from (a link to the page of the box), uploads in progress, and whether it deviates from the other boxes.
- A page with all versions: their states, which can be set by hand (section 6.2), which box runs which version and which one the library runs itself, and on which box a version is prepared: the box has fetched it and tries it on its next start (section 6.3). A release that is distributed and not active yet shows up there.
- A revert in the library concerns the version of the boxes, not the library itself.
6.14 The slot archive¶
What the library hands to a box, what a release contains for the boxes and what tinyjukebox-slot update
FILE takes is the same thing: one file per version and platform, and a small description that is read before anything
is downloaded.
tinyjukebox-box-<version>-<platform>.tar.gz the slot
tinyjukebox-box-<version>-<platform>.tar.gz.sig its signature
tinyjukebox-box-<version>-<platform>.json its description
| Field of the description | Meaning |
|---|---|
version, commit, built |
what it is |
platform |
processor and operating system it was built for, e.g. aarch64 and Debian trixie |
size, sha256 |
of the archive |
interface |
version of the interface to the library that this version speaks (section 4.10) |
system |
what it needs from the system: level of the setup the installer made, version of the slot script |
The archive unpacks into one folder that needs nothing from the other slot:
VERSION the version, one line
description.json the description from above, without `size` and `sha256` of the archive
SHA256SUMS checksum of every file: verified after unpacking, and the basis for "modified"
SHA256SUMS.sig the signature of SHA256SUMS, which SHA256SUMS does not name: the box trusts bin/rfid-reader
by it (box.md, section 7). Versions up to 0.44 have none
python/ the Python interpreter with tinyjukebox and all dependencies, built for the platform
config/tinyjukebox.yaml the config of this version; the values of this box come from outside the slot
bin/tinyjukebox starts the application with paths relative to the slot
bin/tinyjukebox-users, bin/tinyjukebox-import-cards, bin/tinyjukebox-migrate-01, bin/tinyjukebox-fill-library
the other commands of tinyjukebox, with the Python of the slot
bin/rfid-reader the program that reads the tags
bin/rfid-reader-licenses/
the licence texts of the libraries inside it: bcm2835 (GPL-3.0), RPi-MFRC522 (Unlicense)
- Self-contained and movable. The same archive must run from
slots/aand fromslots/b. It therefore contains its own Python and no virtual environment with fixed paths; everything is found relative to the slot. - Not in it: the start script and
tinyjukebox-slot, operating system packages, the values and the data of the box. - Signed. The pipeline signs the archive with the private key of the releases, a secret of the environment
release; the owner keeps copies (section 8.2). The box verifies the signature withopenssland a public key that the installer brought, before it unpacks anything. A slot is trusted because of its signature, not because the library handed it over. The key is an EC key (P-256), signed and verified withopenssl dgst -sha256; an Ed25519 key does not work with this command. - Signed twice. The same key signs
SHA256SUMSinside the slot, before the slot is packed. The slot scripts do not need it – they verify the archive, andsha256sum -c SHA256SUMSchecks only the files the list names, so the slot script of a box set up before accepts the extra file. It is for the launcher ofrfid-reader, which runs as root and trusts the program of a slot by it, long after the archive is gone – or because the installer listed the program inrfid-reader.trusted(box.md, section 7). - How a box installs it: read the description and check state, platform, system and free space (five times the
size of the archive); download the archive
under a temporary name, continuing after an interruption; compare the checksum and verify the signature; unpack next
to the slots; verify
SHA256SUMS; rename the folder toslots/nextand enter it instate/slots. The next start moves it into its slot (section 6.3). - An archive that fails its checksum or its signature is noted in
state/refusedand reported. The box does not fetch it again by itself, otherwise it would download the same file on every start; by hand it can be tried again. What could not be checked is no reason to refuse: without a readablerelease.pubthe box downloads nothing, and a signature that is no signature at all – an empty file, an error page – is fetched again with the next attempt, with the archive that is there already. The list is kept for one key (state/refused.key): a newrelease.pubfrom the installer clears it, and the box tries the archives again by itself. - Python in the slot: a self-contained, relocatable build (python-build-standalone), reduced to what the box needs. The pipeline fetches a fixed version of it and verifies its checksum.
- A release contains it twice: inside the package for the NAS, and as a file of its own, so that a box can be updated from a file without the library.
6.15 The application and the scripts¶
The scripts in deploy/slot/ were developed and proven in the test bed (section 9). The application uses them
through tinyjukebox.slots:
| Call | When |
|---|---|
tinyjukebox-slot status --json |
on start, for the report to the library, for the page |
_passed 1 |
stage1-seconds after the start, if everything the config asks for runs and no unexpected exception arrived |
_passed 2 |
stage2-seconds after an album started to play because of a tag |
_failure TEXT, then exit status 70 |
an unexpected exception while the version is on probation |
_error TEXT |
the application ends with an error, mostly because it cannot start: its redacted traceback, for the report to the library (section 4.22) |
update --auto |
when the connection to the library comes up and on its event "versions"; on a box without a library after the start and once a day (section 6.16) |
_fit, _reported, _discarded |
what the library was told |
check, update, revert, use, fallback, promote, mode, skip |
tab "System", and the banner of a box that asks |
- Unexpected exceptions arrive at
tinyjukebox.core.unexpected: fromLoggerThread, the dispatch of the messages, the worker of a state machine, a periodic task, a request of the web page and the synchronization. - Without slots – the PC, a box set up without them –
Slots.availableis false and nothing of this happens. The box needstinyjukebox-slotin itsPATHand an active slot. - The slot archive is built by
scripts/build_slot.shon the architecture it is for;scripts/build_slot_aarch64.shruns it in an aarch64 container. It holds the self-contained Python,tinyjukebox[box]in the versions ofuv.lock,rfid-reader, the config of the version and launchers inbin/. A box needs the librarieslibasound2andliblgpio1for it, which the installer installs. - The key of the releases never enters the container. In it runs code of others: packages of the operating
system, the sources of the dependencies that are built there, what the cache brings back. The container builds the
folder of the slot only (
build_slot.sh --unpacked), with uv in a fixed version that is checked against the checksum of its release. Outside it,build_slot.sh --packchecks the folder against itsSHA256SUMS– a file the list does not name stops it –, signsSHA256SUMS, packs the folder with GNU tar (sorted, owner root), writes the description with size and checksum and signs the archive. - For a slot nothing is compiled on a box or on the NAS. The archive is built once per release, emulated on an
x86 computer: about eight minutes from nothing.
build_slot.sh --cache DIRkeeps the installed dependencies (as long asuv.locknames the same ones) andrfid-reader(as long as its source and its libraries are the same); with both in the cache the container installs no compiler and the build takes under two minutes. The workflow keeps the cache between its runs; on the PC it is~/.cache/tinyjukebox/slot. - In the container uv unpacks archives the way it did before 0.13.0 (
UV_LEGACY_TAR_BACKEND=1, set bybuild_slot_aarch64.sh). Since 0.13.0 uv unpacks with the system callopenat2, and an emulator older than QEMU 10.0.8 passes one of its flags on wrongly for an aarch64 program (issue 3262 of QEMU): the kernel refuses the call, and uv cannot even unpack the Python it fetches for itself –failed to inspect python/lib/libpython3.13.so: Invalid argument (os error 22), before anything is built. Docker Desktop brings such an emulator (QEMU 10.0.4 in October 2026); the workflow installs a current one and does not need the switch, which does no harm there. What is in the slot is the same either way. When the switch can go: roadmap.md, section 3. - The service unit (
deploy/slot/tinyjukebox.service) starts the start script withKillMode=mixed: the stop signal goes to the start script only, which passes it on. The application ignores further stop signals once it stops; a second one used to interrupt the stop, and systemd then killed the service after 90 seconds. - The installer sets a box up with slots, unless it is run with
--no-slots– by the playbook withtinyjukebox_slots: false(deployment.md, sections 3 and 5).
6.16 A box without a library: the releases¶
A box that is not linked to a library asks the git server for the newest release itself and installs it into a slot.
Everything after the download is what sections 6.3 to 6.5 describe: checksum, signature, slots/next, probation,
health check, falling back.
box with a library: library ─► list of versions ─► slot
box without a library: releases ─► newest release ─► slot
Which box asks the releases. One on which no library is entered (identity/library is empty or missing) and
whose slot.conf names the address of the releases (RELEASES, e.g.
https://api.github.com/repos/<owner>/<repository>). The source follows from that; it is no setting.
- As long as a box is linked to a library, it gets nothing from the releases – not by itself, and not with "Look
for a new version" or
tinyjukebox-slot updateeither. This also holds while the library cannot be reached. The library offers a box only versions whose interface it serves; a box that went ahead by itself would come home with a version the library is too old for, and then it plays nothing (section 4.15). An update from a file stays possible. - The page says so: the section "Version" names the source – the library with its address and that nothing comes from the releases while the link exists, or the releases with their address and waiting time. The link is shown and removed in the section "Library" (section 4.20). A box that is unlinked looks at the releases at once.
What the box asks, and what it fetches:
| Step | Request | Size |
|---|---|---|
| Which version is the newest | GET <releases>/releases/latest: version, date, the text of the release, its files |
a few kB |
| Can this box run it | the description tinyjukebox-box-<version>-<platform>.json of the release (section 6.14) |
330 bytes |
| The version itself | the archive and its signature, continuing after an interruption | about 14 MB |
- The first two steps are "looking", the third is "downloading". Looking writes
state/versions.jsonin the shape of the list of the library, with this one version in the stateuse. - When: the application lets the script look a minute after the start and then once a day (
sync/look-hours). If the git server gives no answer – no WLAN yet, the clock not set – the script ends with status 69, and the application asks again ten minutes later; nothing is shown. An answer that is an error – no release, or a private repository and no token – is no reason to ask again before the next day. - Only the newest release is asked for. A release that is replaced before the box installed it is never
downloaded. A specific older one is fetched by its tag:
tinyjukebox-slot update VERSION. - A release without a slot for the platform of the box offers nothing. A version that needs a newer setup or slot script is not installed, and the page says that the installer of the box has to run again (section 6.14).
- Trust is unchanged: the checksum from the description and the signature against
release.pub, both before anything is unpacked; an archive that fails is noted instate/refusedand not fetched again by itself. The connection is HTTPS with the certificate authorities of the system. - What leaves the box: the request. The git server sees the address of the household and that somebody asked for the release. No id of the box, no version, nothing of the journal is sent.
The modes of a box without a library (state/update, section 6.2):
| Mode | The box |
|---|---|
| automatic | looks, and installs a release that is newer than every version it has run – after the waiting time |
| manual – "ask" on the page | looks and asks: a banner at the top of every tab and the section "Version" show the release with "Install" and "Skip". It downloads nothing until a person says so |
| off | does not look, and contacts nobody because of versions. "Look for a new version", tinyjukebox-slot update and a file still work |
- A release waits before a box installs it by itself:
WAIT_DAYSofslot.conf, three days by default, counted from the day it was published. A release that is followed by a correction within that time is never downloaded, and one that is withdrawn within that time never reaches a box. The boxes with a library get a release at once and try it first. - The page shows the waiting and ends it: "Version 0.21.0 of 12/10/2026, 14 MB – installed by itself on
15/10/2026 (it waits 3 days)" with the button "Install now". That is
tinyjukebox-slot update: the same installation, at once; the version becomes active with the next start.status --jsonsays what the page shows (offer: version, date, size, text of the release, the day it is due). No banner: nothing waits for an answer. - A box that asks: the banner "Version 0.21.0 is available." is shown to admins only – only they can answer –
on every tab, as long as the release waits for an answer; an admin who never opens the tab "System" learns of it
that way. It is no popup and changes no tab. "What is new" leads to the section "Version" and shows the text of
the release, which is the section of
CHANGELOG.mdfor that version. - "Install" is
tinyjukebox-slot update: the box downloads in the background, verifies and unpacks; the version becomes active with the next start, on probation like every new version. "Update now" stays for at once. - "Skip" is
tinyjukebox-slot skip VERSION(state/skipped): the box asks again only for a release that is newer. A skipped release can still be installed by hand. Skipping is an answer to a question: a box in the mode "automatic" does not know it. - Releases in a row cost nothing: the box asks for the newest one only, and of the ones in between nothing was ever downloaded.
- Withdrawing a release: mark it as "pre-release" on GitHub, or delete the release; its tag stays (section 8.1).
The git server then names the release before as the newest. No box without a library is offered the withdrawn
one, and a box that has it in
slots/next– installed by itself, not started yet – drops it. A box that runs it keeps it. A library that fetched it already keeps it in the stateuse: there it is set todontuse(section 6.2). - A version this box went back from is
manualon this box, as with a library: the box never moves forward to it by itself. Nobody else learns of it.
What such a box does not have:
| With a library | Without |
|---|---|
the states use, manual, dontuse, the same for all boxes |
the newest release counts as use; manual only for versions this box went back from |
dontuse: every box leaves a version at once |
not available. A bad release is withdrawn and followed by a better one; a box that runs it goes back by hand or with the next release |
| a box that goes back warns the others | every box finds out for itself, with its own health check |
| older versions from the archive of the library | by their tag, as long as the release exists |
Public repository, or a token. Both work, for a box and for the library:
| Without a token | With a token | |
|---|---|---|
| Repository | public | private, or public |
| The box | sends no credentials; GitHub allows 60 requests per hour and address | the file identity/releases-token, readable by the service user only, written by the installer (--token-file; the playbook from the vault, vault_releases_token) |
| The library | asks without credentials only where its config allows it; the package on the NAS needs a token or an empty token file (section 8.2) | set on its page, or its token file (section 8.2) |
| The files of a release | from their public address | through the interface of the git server |
The rights of the token. A token that may only read the releases of this one repository is enough, for a box and for the library alike. On GitHub that is a fine-grained token (Settings → Developer settings → Personal access tokens → Fine-grained tokens):
| Setting | Value |
|---|---|
| Repository access | "Only select repositories": this one repository |
| Repository permissions | "Contents: Read-only"; GitHub adds "Metadata: Read-only" by itself. Nothing else |
To select the repository is not enough: "Contents" has to be added to the permissions, without it GitHub refuses the releases. A token counts as soon as it is created. Its rights can be changed later on the page of GitHub; the token stays the same, so nothing changes in the vault, on the box or in the library. The token is sent to the git server only, never to the storage service a download is redirected to.
What GitHub answers tells what is wrong with a token:
| Answer | The token |
|---|---|
401 |
is not known: mistyped, withdrawn or expired |
404 |
has no access to this repository |
403 |
has access to the repository, but not the right "Contents: Read-only" |
To ask GitHub by hand; the message of the answer names the reason:
curl -i -H "Authorization: Bearer TOKEN" "https://api.github.com/repos/OWNER/REPOSITORY/releases?per_page=1"
The first version of a box without a library is fetched by the box as well: the installer calls tinyjukebox-slot
update with its own version on a box that has no slot and was given no archive (deployment.md,
section 5).
The scripts lie outside the slots and change only through the installer: a box that was set up before version 0.21.0
gets all of this with the next run of the installer or the playbook. The page offers the mode "off" only if the slot
script of the box knows it (script of status --json).
Tests: tests/sync/test_box_releases.py, with a stand-in for the git server (tests/sync/gitserver.py).
7. Journal and play statistics¶
The statistics need the same events as the proof "a tag was recognized and played" (section 6.4) and the transfer of the positions. The box therefore keeps one journal, a file that is only appended to. The positions themselves stay data of their own (section 4.1); journal and positions are sent together.
| Event | Content |
|---|---|
| Tag placed | UID, album or station, output (speaker or headphones) |
| Track played | track number, seconds listened, played to the end or skipped; for an entry of Spotify also the address and the title of the track – its skipped tracks are counted by address |
| File that the decoder found damaged | album, file, what the decoder said |
| Pause, next, previous | time; caused by button, headset or web page |
| End | reason: tag removed (taken off), tag replaced, album finished, error |
| Start of the box | version, slot, on probation or confirmed |
| Still switched on | nothing else: a line every ten minutes while the box runs |
| Change of version, revert, update by hand | from, to, cause, by whom |
| Conflict, rejected change | as in the log (section 4.5) |
- Every entry carries the box, the start and a running number. The library takes each entry exactly once and never changes it; it can only flag it as discarded (section 4.11).
- A line is on the card before the box goes on. The number is saved first, then the line is appended and synced to the card. The box is switched off by cutting the power: at most the line that was being written is lost.
- A lost line becomes a mark,
recovery_gap. The library takes the lines only without a gap, so a missing number would stop the journal of the box. On every start the box keeps every complete line of its journal and writes a linerecovery_gapfor each number whose line is missing: a damaged line (e.g. zeros left by a power cut), and a number that was saved but whose line never reached the card. If the library waits for numbers that the box no longer has – lost on both sides, or a library restored from a backup – the box writes marks for them too. A mark carries only its number, the start and the version: nothing is invented, and the statistics ignore it. The box reports the lost numbers (section 4.21). - Switching the box off leaves no "end" event. The library recognizes this by the next start and closes the session with the last known position. How long the box ran is what the last line of a start says; the line "still switched on" is written for that, so that the hours after the last tag count too.
- The journal is transferred in batches together with the positions – on pause, change of track and taking off the tag. Without WLAN the box collects and delivers later.
- Clock: every entry also carries the time since the start and a note whether the clock was synchronized. The library corrects the times afterwards as soon as the box was online once in the same run. For a run entirely without WLAN the durations are right and only the time of day is unknown.
- Store: SQLite in the library (part of Python); the catalog stays JSON.
What can be read from it. The statistics have a tab "Statistics" on the page of the library and on the page of every box. They are open to every user of the page; the journal behind them is for admins:
- per tag: how often and how long it was played, when last, how often to the end, which tracks are skipped. The table is sorted by the column that is clicked
- per day: listening time, between which hours, the output (
speaker,headphones) - stations: listening time per station
- operation, per version: starts, errors and fallbacks; the days it was installed – from its first start on a box to the first start of another version, for the version a box has now until today; the hours the box was switched on with it, and the hours of these in which something was listened to
- tags that are never placed
One box or all boxes. A box is the box of one child, so the statistics are those of one box wherever a sum would say nothing:
- The page of a box shows what was played on this box, and nothing of any other box.
- The page of a library with one box shows the same. With several boxes it offers the choice "All boxes" or one of
them (
GET /api/library/statistics?box=). A box that was removed stays in the choice as long as the journal has lines of it. - "All boxes" sums what is a property of the catalog or of the software: per tag (with the listening time on every box next to it), stations, skipped tracks, operation per version, and the tags that were placed on no box. A table of the boxes says how long each one played, how often a tag was placed and when last; a click on a box chooses it.
- The days are never summed: with "All boxes" the table has one row per day and box. Two children who listen on the same day are two rows, each with its own hours and its own output.
Where a box gets its statistics. The library keeps the journal; a box deletes what was confirmed. The page of a
box therefore asks the library when the tab is opened (GET /api/box/statistics on the box, which asks
GET /api/sync/v2/boxes/{box}/statistics); the key of a box is valid for its own statistics only. The same function
counts in both places (library/statistics.py); one box is counted by passing its lines only.
| The box | Its tab shows |
|---|---|
| works with a library that answers | what the library counted for it. Lines the box has not delivered yet are missing |
| cannot reach its library | a sentence that says so, no numbers |
| is newer than its library (the package of the library is installed by hand) | a sentence that the library has to be updated: the library answers 404 to a path it does not know |
| has no library | what its own journal says: the last 1000 lines, which the tab states |
Without a connection to the library the box keeps writing its journal to the SD card. Every line has
a running number, and the library confirms up to which number it has received the lines. With the next contact the box
sends everything after that number, in order and in batches, and deletes what was confirmed. Nothing is lost and nothing
is counted twice; the statistics only lag behind. The page of the library shows the last contact of every box, so that
missing days are read as "not delivered yet" and not as "nothing played". A box for which no library is configured keeps
only a limited journal, like the log file (JOURNAL_LIMIT, 1000 lines); it writes no line "still switched on", which
would fill that journal within a week. Its backup holds these lines (json/journal.jsonl,
operations.md, section 10): nobody else has them.
The journal is an exact listening record of a child. It stays on the NAS; nothing of it goes to GitHub, and the error reports of a box end in the library too.
Broken files. The library collects per album which files are broken: from its own check of every track, and from
the journal lines broken of the boxes (section 4.18).
8. Release pipeline¶
push ─► tests ─► build package, slot, installer, app, wheel; try the image ─► artifact
push to master ─► the same ─► tag vX.Y.Z and release: package for the NAS, signed slot for the boxes, installer,
app, wheel ─► image on ghcr.io, package on PyPI (public repository only)
library: newer release? ─► fetch the slot for the boxes ─► checksum ─► archive ─► event to the boxes
package for the NAS: created by a script, installed by hand
- Build on every push, so that a change that breaks the package or the slot is noticed at once. Only a push to
mastersigns with the keys of the releases; every other branch signs with keys made for its run, which no box and no phone trusts – signing and checking are tried all the same, and no branch hands out a slot that a box would install. - Library and boxes in the tests:
tests/syncruns a library and boxes in one process over HTTPS (section 10), so the protocol is tested on every push. - A push to
masteris a release if it changes the software and passes the checks of section 8.1: the workflow creates the tagvX.Y.Zfrom the version inpyproject.tomland the release. Nobody tags by hand, so tag and version cannot differ. A release holds the package for the NAS, the signed slot for the boxes (section 6.14), the installer of a box (install.sh), the app for phones, wheel and source archive, as separate files, andSHA256SUMS.
| Form | For | Published |
|---|---|---|
| slot archive, signed | the software of a box | file of the release |
package .spk |
the library on the Synology NAS | file of the release |
installer install.sh |
the system of a box and its first version (deployment.md, section 5) | file of the release; the library keeps it with the slot |
app tinyjukebox-app-<version>.apk, signed |
phones and tablets with Android (app.md) | file of the release; the library keeps it with the slot and hands it out at /app.apk |
| wheel, source archive | the library and a box without hardware on a PC: pip install tinyjukebox |
file of the release; PyPI |
SHA256SUMS |
the checksums of the other files of the release; fetch_release.sh checks them |
file of the release |
| Docker image | the library; a box without hardware (section 3.9) | ghcr.io/tgd1975/tinyjukebox, tags <version> and latest |
All are built from the same commit and carry the same version: the files by the job build, the image for both
platforms by the job publish, from the wheel of that build (section 8.2). The slot stays the only way
software reaches a real box: neither the image nor PyPI brings rfid-reader, the tested set of versions or the
way back. pip install tinyjukebox[box] installs the hardware libraries and compiles them on a Pi, but is no way to
run a real box.
- The library on the NAS is not updated automatically. A script in the repository creates the package; the same
script runs on the PC and in the workflow. The package is installed by hand in the Package Center. The library only
shows that a newer version of itself exists.
- The library fetches the versions for the boxes. GitHub cannot reach the home network, so the
library asks GitHub for the newest release – with a token that may only read this repository, or without one where
its config allows it (section 8.2), and with a conditional request, so that the answer "unchanged" costs nothing.
It downloads the slot, verifies the checksum, puts it into its archive in the state use and tells the boxes. A
box with a library never needs access to GitHub; a box without one asks GitHub itself (section 6.16).
- A box version may be newer than the library. The description of a slot names the version of the interface it
speaks. If the installed library does not serve it yet, the version is shown as "needs a newer library" and is not
offered to the boxes until the package has been updated.
- The interval follows the activity. Work on the software comes in bursts with long quiet periods:
| Situation | Interval |
|---|---|
| quiet | 6 hours |
| a new release was sighted, or a check was triggered by hand | 10 minutes, for the next 6 hours |
| afterwards | longer step by step, until it is 6 hours again after two weeks |
All four values can be configured. Every new sighting and every check by hand starts the schedule again.
- A check can be triggered at once: with a button on the page of the library, and with a script in the repository,
check_release.sh, which asks the library to look now. Both fetch a version that is found and shorten the interval as
in the table.
- The workflow stays thin. Building, checksums and the comparison of tag and version are scripts in the repository;
the workflow only calls them. Only the step that creates the release depends on the platform.
| Way from GitHub into the home network | Assessment |
|---|---|
| The library on the NAS asks | chosen: no foreign service, no open port |
| Relay service that the NAS keeps a connection to | a real push, but a third party in the chain |
| Workflow runner in the home network | probably too heavy for the NAS; a box is switched off too often |
| Webhook through a forwarded port or a tunnel | opens the network for something that is not urgent |
8.1 Checks before a release¶
A commit on master can become a release that the boxes install by themselves. Nothing may become a release without
passing these checks. The job publish, which creates the release, runs none of them: it runs only after the job
build, which needs the jobs test and app and checks the version and the changelog itself (section 8.2). If one
of them fails, nothing is published.
| Check | How |
|---|---|
| All tests pass that can run on GitHub | the job test of the workflow. All tests of tests/ run there; the hardware has no automated tests |
| The sync module of the app works with the library of the commit | the job app: the JVM tests of the sync module of the app (app.md) |
ruff check and ruff format --check pass |
steps of the job test, together with mypy |
| The version was bumped | version in pyproject.toml must be X.Y.Z and higher than the one of the last release; only tags vX.Y.Z count, because sort -V puts 0.21.0rc1 after 0.21.0 |
| The changelog was updated | CHANGELOG.md must have a section for exactly this version |
- The checks guard the release, not the branch. The branch
masteris not protected: a push to it stays possible, but it cannot become a release without passing the checks. On another branch a missing bump or section is a warning: the branch is built and tried without a release, and the version is raised before it goes tomaster. - A bump is required only when the software changes:
src/,cpp/,deploy/,scripts/,app/,build_package.sh,pyproject.toml,uv.lock. A push that changes only documentation, tests or hardware files needs neither a bump nor a changelog entry and leads to no release. Otherwise every corrected typing error in a document would make all boxes download and try a new version. - A tag
v*is created only by the workflow and never moved or deleted, because the boxes rely on a version always being the same software. - "Changed" and "higher" refer to the last release the commit descends from: the newest tag
v*that is reachable from it. One script decides this and prints whether the commit becomes a release; the workflow only reads its answer. CHANGELOG.mdis English like the developer documentation and has one section per version with its date; a release takes its text from the section of its version.uv.lockis covered already: the workflow installs with--lockedand fails if the lock file does not match the bumped version.
8.2 The workflow¶
A push runs the job test (Python 3.13 and 3.12) and the job app (the JVM tests of the sync module of the app
against a library of the commit). The job build follows on every push; it may only read:
scripts/ci/release_needed.sh: has the software changed since the last tagvX.Y.Z–src,cpp,deploy,scripts,app,build_package.sh,pyproject.toml,uv.lock? If so, the version inpyproject.tomlmust be higher andCHANGELOG.mdmust have a section for it; otherwise the job fails onmaster. On another branch that is a warning and no release, and the branch is built all the same.scripts/ci/build.sh, in four steps, each with the one key it needs:slotbuilds the folder of the slot for aarch64 in a container, without any key (section 6.15);signsigns and packs it and checks both signatures – onmasteragainstdeploy/release.pub, so a secret that does not fit the boxes stops here;appbuilds the signed app for phones (scripts/build_app.sh);packagethe installer, the package with this slot, installer and app, wheel and source archive,SHA256SUMS. Onlymastergets the keys of the releases; every other branch signs with keys made for its run.scripts/ci/test_image.sh: the image for the runner from the wheel, loaded only for this test; a library and a box linked to it start, and the box registers.
The job publish runs on master only, if step 1 said so, after the job build, and runs no check of its own. It
publishes what the job build built and tried – its artifact; only the image is built anew. Only this job may write
(contents, packages, id-token). It runs one at a time, and first asks step 1 again with the tags of now: two
pushes in a row both decided before the first had its tag, and the second one would put its own build into the
release of the first (seen with 0.47.0). If the tag of the version is on its commit, the job is run again and goes on:
scripts/ci/release_github.shcreates the tag and the release.- Then, only from a public repository – both publish the code:
scripts/ci/publish_image.shbuilds the image for both platforms from the wheel of the artifact –arm64emulated, like the slot – and pushes it to the registry of GitHub,scripts/ci/publish_pypi.shuploads wheel and source archive to PyPI. The boxes depend on step 4 only.
Every step can be repeated. If a later step fails, the job is run again: a release, an image or a file on PyPI that
is there already is skipped. A version on PyPI is final – it cannot be replaced, only withdrawn ("yanked") and
followed by the next one, as a tag is never moved. gh release create makes a draft, uploads the files and then
publishes it; a job cancelled in between leaves the draft. The next run publishes a draft of the same commit with all
its files, deletes any other one and creates the release anew, and uploads a file that a published release lacks.
release_github.sh never ends without a published release that holds every file.
Every action is pinned to a commit, with its version in a comment; dependabot proposes the next versions as pull
requests (.github/dependabot.yml), as it does for the base image of the Docker image. A pull request from a fork
gets no secret: the jobs build and publish run for a push only.
Set once, by hand:
- The environment
release(Settings → Environments): deployment branches "Selected branches", onlymaster; the secretsSIGNING_KEY,APP_SIGNING_KEYandAPP_SIGNING_PASSWORDare secrets of this environment, not of the repository – a workflow changed on another branch cannot read them. The jobsbuildofmasterandpublishrun in it. - PyPI: an account, and in it this repository with its workflow as the "trusted publisher" of the project
tinyjukebox(Trusted Publishing: PyPI trusts the workflow, no password is stored; the job proves its identity withid-token: write), with the environmentrelease. It can be set before the first upload; it does not reserve the name. - The registry of GitHub: nothing. The workflow pushes with its own token (
packages: write), and the image has the visibility of the repository it names (org.opencontainers.image.source). - Docker Hub: not used.
README.md is the page of the project on PyPI, where relative links lead nowhere: the build makes them full addresses
of the repository (hatch-fancy-pypi-readme); the file in the repository stays as it is.
To make a release: raise the version, run uv lock, add the section to CHANGELOG.md, push to master. Nobody
tags by hand.
| Key | Where |
|---|---|
| private key of the releases | secret SIGNING_KEY of the environment release; copies on the PC in ~/.config/tinyjukebox/release.key and in the vault of the playbook |
| public key | deploy/release.pub; the installer brings it and installs it as /srv/tinyjukebox/release.pub, and once more as /usr/local/lib/tinyjukebox/release.pub for the launcher of rfid-reader (box.md, section 7) |
| key of the app for phones | secrets APP_SIGNING_KEY (the keystore, Base64) and APP_SIGNING_PASSWORD of the environment release; copies on the PC in ~/.config/tinyjukebox/app.keystore and app.keystore.password and in the vault of the playbook. Made once (2026-11-07): keytool -genkeypair -keystore app.keystore -alias tinyjukebox -keyalg RSA -keysize 4096 -validity 36500, then base64 -w0 app.keystore. Lost, no phone takes a new release as an update |
scripts/check_release.sh URL [TOKEN-FILE] asks the library to look for a release at once, with a token for scripts
that an admin creates in the tab "Boxes". It reads the token from ~/.config/tinyjukebox/library-token, or from the
file given after URL.
Scripts on the PC send a password or a token only to a key they know (scripts/trust.sh): the library and DSM
have certificates of their own, which no authority signed. The first time, check_release.sh, update_library.sh
and release.sh show the certificate – subject, end, SHA-256 fingerprint and the public key – and ask; the key that
was accepted is kept in ~/.config/tinyjukebox/pins and is the only one curl accepts from then on
(--pinnedpubkey). The fingerprint is compared with the one the browser shows, the key of the library also with the
pin of the line in its tab "Boxes". Without a terminal nothing is asked and nothing sent. For check_release.sh and
update_library.sh, --pin sha256//… gives the key, --cacert FILE an authority instead; release.sh takes only
the NAS. For all three the variable TINYJUKEBOX_CACERT names such an authority. A server that shows another key
later – a certificate renewed with a new key – is refused with the reason, until its line is removed from the file.
The token for the releases. The library asks for releases only if its config names a repository; without one
it never asks – the library on the PC (section 3.8) fetches no versions. Which token it sends:
| Config and page | The library |
|---|---|
| a token set on its page (tab "Versions") | asks with it; it comes before the token file |
a token in the token file of the config (token-file) |
asks with it |
| an empty token file | asks without credentials: enough for a public repository |
| a token file that is not there, and no token on the page | does not ask, also not a public repository; the page says that the token is missing |
| no token file in the config, and no token on the page | asks without credentials |
The package on the NAS always names var/github-token: it needs a token, or an empty file there, also for a public
repository. The config that Docker and --data make (section 3.9) names no token file, so they ask a public
repository without credentials. The token file is read when the library starts. A fine-grained token for this one
repository with "Contents: Read-only" is enough: the library reads the release and fetches the slot with it, and
GitHub refuses every write. How it is created, and what the answers of GitHub to a token mean: section 6.16.
9. Test bed¶
The test bed tests the interactions between developer, git server, library and box in a set-up of several Docker containers: releases with built-in defects, power cuts at named places, two networks that are cut separately. It proved the mechanics before Tiny Jukebox was changed, with a trivial application in place of Tiny Jukebox.
The test bed is not kept. It was a repository of its own and was removed in October 2026, when the mechanics had moved into Tiny Jukebox. This section is its description: what it consists of, the scenarios it runs, and what to do to build it again (section 9.8).
| What it tested | Tested today |
|---|---|
| Synchronization, files, access (S, F, A of section 9.5) | tests/sync, a library and boxes in one process (section 10) |
| Health check, reports, a revert, an update from the library (U1, R1) | tests/sync/test_slots.py with the real slot scripts on a folder |
| A version that crashes or hangs under systemd (U2, U14) | deploy/install/test/run.sh, by hand |
A link that points to nothing (R7); a note of a switch that a power cut left empty, with a trigger file and with a version that waits in slots/next; a slot changed by hand and an update by itself (U10) |
tests/sync/test_slots.py, tests/sync/test_box_releases.py |
The slot scripts beyond that: power cuts while unpacking and switching, trigger files, a link changed by hand, the fallback slot, --root (U3 to U13, R2 to R11) |
nothing – roadmap.md, section 3 |
| The release chain with a git server and a workflow (C) | the real chain: every release of Tiny Jukebox takes it |
When to build it again: before a change of the slot scripts in deploy/slot/ that goes beyond a correction,
of what never changes in the interface (section 4.10), or of the way a box and the library treat a version that
does not fit – everything where a mistake leaves a box that cannot update itself any more.
9.1 Mechanics and payload¶
The test bed contains the real mechanics with a trivial payload:
- the slot switcher with download and installation (shell) – the scripts of
deploy/slot/ - the synchronization: copy, outbox, revisions, id table (Python)
- the file transfer with a manifest (Python)
- the query for releases (Python)
The payload of the first test bed was a notes application without any logic, because Tiny Jukebox had none of the
mechanics yet. It carried them itself, about 3500 lines of Python with the standard library only, which then moved
into tinyjukebox.sync and tinyjukebox.library:
| In the test bed | Stands for |
|---|---|
| a note with id and text | tag, album entry, station |
| a counter per box | playback position |
| a package of a few files with a manifest | album |
| "a note was read" | a tag was recognized and played (stage 2 of the health check) |
A new test bed has no reason to copy the mechanics again. Its payload is Tiny Jukebox itself: tinyjukebox --no-hardware
--no-audio in a slot, where the virtual reader places a tag, and tinyjukebox-library from its package – as
tests/sync/bed.py runs them in one process. The scenarios below speak of notes, counters and packages; read them
with the table above.
9.2 What is left of it¶
| Part | Where |
|---|---|
| The slot scripts | deploy/slot/. They are the scripts of the test bed, with one correction made since (slot_set replaces state/slots in one step) |
| The breakpoints of the scripts (section 9.4) | still in the scripts: breakpoint NAME in tinyjukebox-functions, called at unpacked, downloaded, slot-moving and trigger-switched. Without the file testbed/breakpoint in /srv/tinyjukebox they do nothing |
--root and PLATFORM in slot.conf |
what lets the scripts run in a container and on a mounted volume |
| The scenario numbers | section 9.5; tests/sync names its tests after them |
| A defective version made from a real slot archive | deploy/install/test/defective_slot.sh (crash, hang) |
| The stand-in for a box with systemd | deploy/install/test/ |
Gone with the repository: the notes application, the driver with its 63 scenarios (about 2300 lines of Python), the Docker files (about 230 lines), the stand-ins for systemd and the Package Center, and the workflow for Gitea.
9.3 Containers¶
flowchart LR
driver["driver<br/>plays developer and user"]
subgraph internet [network internet]
git["git<br/>Gitea: repository, releases"]
ci["ci<br/>runs the workflow"]
end
server["server<br/>stands for the NAS"]
subgraph lan [network lan]
box1["box1"]
box2["box2"]
end
ci --> git
server -->|asks for releases| git
box1 <--> server
box2 <--> server
driver -.-> git
driver -.-> server
driver -.-> box1
driver -.-> box2
| Container | Image | Role |
|---|---|---|
git |
gitea/gitea (1.27), SQLite, registration off, Actions on, its data in memory |
a git server with releases and workflows; stands for GitHub |
ci |
Python 3.13 with git, curl, jq, openssl, and act_runner of Gitea |
the runner. It registers with a fixed token and executes the workflow directly in its container (label testbed:host), without Docker and without internet |
server |
Python 3.12, as the NAS | stands for the NAS: the library, installed from its package |
box1, box2 |
Debian trixie with bash, curl, jq, openssl, util-linux, procps – no Python |
a box each: the slot scripts in /usr/local/lib/tinyjukebox; a volume /card as SD card with srv/tinyjukebox and boot/firmware, linked to /srv and /boot/firmware |
driver |
Python 3.13 with pytest, git, the Docker client |
plays developer and user: pushes, publishes releases, changes notes here and there, disturbs, checks. It has the Docker socket, the repository and the volumes of the others mounted |
Networks: internet (git, ci) and lan (boxes); server and driver are in both. A box that is disconnected from
lan is "at the grandparents": internet yes, library no.
What stands for what:
| On the real system | In the test bed |
|---|---|
systemd unit with Restart=on-failure |
box-init, the first process of a box container: a loop that starts tinyjukebox-start and starts it again after a failure, not after a clean end. box-service start\|stop\|restart\|status stands for systemctl and is the RESTART_COMMAND of slot.conf |
| Package Center, "Manual Install" | the driver puts a package into a folder of the server volume and restarts the container; server-init unpacks the newest package and starts the library |
| installation dialog of the package | the driver writes the config of the library |
| Ansible playbook | a function of the driver: slot.conf, identity/library, identity/bootstrap, release.pub, the first version with tinyjukebox-slot update FILE |
| power cut | docker kill |
| SD card in the PC | the volume of a box as the driver sees it; tinyjukebox-slot --root works on it |
| "Stage 1 must pass in time" | STAGE1_LIMIT=6 in slot.conf, so that a scenario takes seconds |
The git container is disposable. Every test run starts it empty; the driver creates user, token and repository and pushes the code. A plain git server would not do: it knows neither releases nor a build.
- The whole chain is real: a push arrives, the workflow runs the checks and creates tag and release, the server finds the release.
- Trying by hand: Gitea has a web page on the PC. Push from a clone, look at a release, watch the boxes react.
- Two ways to a release: the many deliberately defective releases are uploaded by the driver through the interface of Gitea, which takes seconds. Only a few scenarios take the slower way through the workflow.
- The rules for a release apply to the repository in the git container (section 8.1): a push that changes its software needs a higher version and a section in its changelog.
- A box container has no Python, only the shell tools of section 6.8: the application runs with the Python of its slot, as on a real box. The server container has Python 3.12, as the NAS.
- Everything a run writes lies in memory: the volumes that stand for the SD cards and the NAS are tmpfs, and so is the data of Gitea. Docker Desktop keeps its disk in a file that never shrinks; a test bed that rewrites its state on disk for every scenario fills the PC.
- Every scenario starts from the same saved state: the library and two boxes that run a confirmed version and have a fallback version. The driver sets this up once, stops everything and saves each volume in a folder of the same volume as hard links; before a scenario it stops the containers, puts the saved state back – the slots and the archive as hard links, the rest as copies – removes all releases from the git server and starts everything. A second saved state has both boxes on the second version. A saved state carries a mark of the software it was made with and is made anew when the software changed.
- Builds are kept. The driver builds every slot and package once per version and set of faults, with the scripts of the repository, and reuses it; the self-contained Python of the slots is fetched once into the image.
9.4 Faults on demand¶
- Defective releases. Every release is built from the same code plus a built-in switch, a file next to the code
that the build script writes (
build_slot.sh --fault NAME[=VALUE]):
| Switch | The version |
|---|---|
crash-on-start, hang-on-start |
ends at once with an error; never passes stage 1 |
missing-component |
runs, but something the config asks for does not |
never-reads |
never passes stage 2 |
exception-on-read=SECONDS |
raises an unexpected exception that long after it played |
bad-counter |
writes broken data |
wrong-checksum |
its archive does not fit its description |
system=2, interface=2 |
needs a newer setup of the system; speaks the next interface version. The same switch builds a library that serves the next interface version |
A second key signs the slot whose signature does not fit (C2). - Breakpoints. Script and application stop at named places on request and say so in a file; the driver waits for the file and kills the container exactly there, which makes the power-cut cases repeatable. The scripts have theirs (section 9.2). The application needs them after it got the answer to its changes, after it sent a file and after it fetched one, the library after it stored a change (S6, F2). - Faults at run time. A file names a fault and how often it happens, e.g. a file that arrives with a wrong checksum (F5). - Network. The driver disconnects and connects the two networks separately, per container. - Clock. Whether the clock of a box counts as synchronized is a switch, because all containers share the clock of the PC. - Library from a backup. The driver saves the volume of the server and puts the older state back.
Only one module of the application knows that it runs in a test bed: it reads the switches and the folder with the files of the driver, which an environment variable names. Without the variable nothing of it is active.
9.5 Scenarios¶
Update chain:
| No. | Scenario | Expected |
|---|---|---|
| U1 | new release without a defect | the server fetches it; the box fetches the slot, switches on the next start, passes both stages; the old slot stays |
| U2 | release that crashes on start | the previous version runs again within seconds; the version is manual for all boxes; the server shows the report |
| U3 | release that never reads a note | the version stays on probation however often the box restarts; the confirmed slot stays; the server shows since when; a revert by hand works |
| U4 | unexpected exception during the probation | the box goes back and reports |
| U5 | newer release during the probation | replaces the unconfirmed version; the confirmed slot stays |
| U6 | mode "manual" | nothing happens by itself; check shows the release; update installs it; the mode stays |
| U7 | update from a file, without the server | installed and on probation |
| U8 | neither slot can be used | recovery mode in the fallback slot installs a version; without a fallback slot, the box stays on the running version and reports |
| U9 | container killed while a slot is unpacked | the incomplete slot is never started; the next attempt unpacks again |
| U10 | slot changed by hand | status shows "modified"; update asks before overwriting |
| U11 | release with a wrong checksum | rejected, ERROR |
| U12 | release that needs a newer system | not installed, reported |
| U13 | recovery mode against the newest server | fetches the newest version from it, although the fallback version speaks an old interface |
| U14 | release that hangs on start | stage 1 does not pass within the time limit; the previous version runs again. A box stopped during stage 1 is not counted |
Revert:
| No. | Scenario | Expected |
|---|---|---|
| R1 | revert on a box while it plays |
the other slot runs at once; the version becomes manual on the server; the other box keeps it if it runs it, and does not install it otherwise |
| R2 | version set to dontuse on the server |
all boxes leave it at once; a stopped box follows after its next start |
| R3 | revert to a specific older version | fetched from the archive; the box does not install the versions in between by itself, but it does install the next release |
| R4 | link changed by hand | detected on the next start, entry "by hand, source unknown", version set to manual, reported |
| R5 | trigger file tinyjukebox-revert, tinyjukebox-fallback, also with .txt |
executed once, file deleted, result file written |
| R6 | container killed between switching and deleting the trigger file | no second revert |
| R7 | link points to nothing | the confirmed version starts – the newer one of two –, without one the fallback slot (recovery mode); logged |
| R8 | next release after a revert | fetched by itself in the mode "automatic", only shown in the mode "manual" |
| R9 | version set back to use on the server |
a box that has not run it yet fetches it and puts it on probation; a box that went back from it gets it only by hand |
| R10 | --root on a mounted volume |
the same result as on the box, without a restart |
| R11 | revert on a box without connection |
the version is manual on the box at once, and on the server after the next contact |
Synchronization:
| No. | Scenario | Expected |
|---|---|---|
| S1 | note changed on the server | the box has it after the event, or after its next start |
| S2 | note changed on a box without connection | effective on the box at once; survives a restart and a new copy; reaches the server later |
| S3 | the same note changed on both sides | the value of the server everywhere; WARNING with both values in both logs and in the journal |
| S4 | the box changes a note that the server deleted | as S3 |
| S5 | both boxes create a note without connection | b1-1 and b2-1 become final ids; the counter is rewritten; no prefix is left |
| S6 | connection cut after the server stored a change | sending again creates no duplicate and returns the same id |
| S7 | a box falls back | working files rebuilt from the copy; writes of the defective version discarded; entries by hand logged, not applied |
| S8 | server restored from an older backup | catalog repaired by checksum; counters: the box wins |
| S9 | invalid change | rejected, ERROR |
| S10 | a box set up again takes over box 1 on the server | new internal id; gets the catalog and the counters and settings of box 1 |
| S11 | counter of box 1 | never appears on box 2 |
| S12 | name of a box changed | the pages show the new name; ids, counters and journal are unaffected |
Files:
| No. | Scenario | Expected |
|---|---|---|
| F1 | package referenced but not on the box | fetched on demand; usable as soon as the first file is there |
| F2 | upload from a box, interrupted by network and by kill | continues; only the missing files are sent |
| F3 | incomplete package | invisible to the other box; no final id; the box keeps its files |
| F4 | note linked to a new package | applied by the server only with the complete package |
| F5 | file with a wrong checksum | discarded and requested again; repeatedly: ERROR |
| F6 | package with identical files exists on the server | resolved to the existing id; nothing uploaded |
| F7 | one file missing on each side | one file copied in each direction |
Release chain:
| No. | Scenario | Expected |
|---|---|---|
| C1 | push with a failing check, without a higher version, or without a section in the changelog | no release |
| C2 | slot whose signature does not fit | the box does not install it and reports it |
| C3 | push that changes only documentation | needs no bump and creates no release |
| C4 | push with a new version that passes the checks | the workflow creates tag and release with package, signed slot and checksums; the server finds the release and fetches the slot |
| C5 | server cannot reach git |
nothing changes; it catches up later |
| C6 | no new release | the answer "unchanged" causes no download |
| C7 | release sighted | the server asks every 10 minutes for 6 hours, then less and less often until the quiet interval is reached |
| C8 | check triggered on the page of the server and by the script | the release is found at once; the interval is shortened as in C7 |
| C9 | version for the boxes that speaks a newer interface than the server | shown as "needs a newer server"; not offered to the boxes |
| C10 | server replaced by a newer version by hand | the boxes keep working; the waiting version is offered |
| C11 | a box is updated from a file to a version whose interface the server does not serve | the box stops working and shows "does not fit"; it does not go back by itself, and no state of a version changes; the server shows which box speaks which version |
| C12 | after C11 the server is replaced by a version that serves the interface | the box works again after its next contact |
| C13 | the server no longer serves the interface of an old box | the server refuses everything but the update; the box works as without a server, fetches the newest version, switches on its next start and synchronizes again; what it collected in the meantime arrives |
| C14 | as C13, with the box in the update mode "manual" | the box stays too old and keeps working without the server; both sides show it; an update by hand resolves it |
Access:
| No. | Scenario | Expected |
|---|---|---|
| A1 | a box with the bootstrap password contacts the server | the handshake succeeds without a click; the box has a key of its own and has stored the certificate; it no longer uses the bootstrap password |
| A4 | request without credentials, or handshake with a wrong bootstrap password | rejected; nothing is answered |
| A5 | bootstrap password changed on the server | boxes that are registered keep working; a new box needs the new password |
| A2 | the server presents another certificate | the box refuses the connection |
| A3 | a box is removed on the server | its key is rejected; the other box keeps working |
Not in the test bed, on purpose. These are tested in Tiny Jukebox (section 10):
- space on the SD card (section 4.16)
- the covers of albums and stations (section 4.8)
- the web pages of library and box, and the statistics (section 7); the test bed has the interface behind the pages
- the limits of the archive of box versions (section 6.13)
- a box whose slot script is too old for a slot (section 4.15); the script has the check, no scenario uses it
9.6 What Docker does not show¶
| Gap | Covered by |
|---|---|
| Real GitHub: workflow runner, token, request limit, files of a private repository | stage 2 (section 9.7) |
| DSM and the package | the package on the NAS |
| systemd on the box | a simple restart loop in the container; the real behaviour only on the box |
| FAT boot partition, a real power cut with an SD card | only on the box |
| Pi 3: speed | only on the box |
| The interface of Gitea is similar to the one of GitHub, not equal | the query for releases has one narrow place with two variants |
9.7 Stages¶
- Everything local in Docker. One script builds the images, starts everything empty and runs all 63 scenarios in the driver container with pytest, in about 10 minutes and without internet once the images are built. The same runs as a job of a workflow on the runner of GitHub.
- Real GitHub. The workflow of the test bed repository builds real artifacts and releases, and the
servercontainer asks the real GitHub: a check of what Gitea cannot show. The boxes start with an older version built on the PC and signed with the same key; the library fetches the slot of the newest release, and a box installs it. Carried out once, on 2026-10-06; what it showed is part of sections 3 and 8.
Docker runs on the PC (Docker Desktop, x86-64). That the box is ARM does not matter for this logic.
9.8 Building it again¶
A repository of its own, private, next to the clone of Tiny Jukebox. What it holds, in the order in which it is built:
- The Docker files: one
Dockerfilewith a target per role and acompose.yamlwith the containers, networks and tmpfs volumes of section 9.3, and the three stand-insbox-init,box-serviceandserver-init. - The software under test, taken from the clone of Tiny Jukebox: the slot scripts of
deploy/slot/into the box image; slots built withscripts/build_slot.shfor the architecture of the PC (it builds for the machine it runs on;--no-rfid-readerleaves the reader out) and signed with a key of the test bed; the package of the library frombuild_package.sh, of whichserver-inituses onlypackage.tgz. The config of a slot is one for a box without hardware and without sound. - The switches of section 9.4 in Tiny Jukebox: the breakpoints of the application and of the library, the faults
at run time and the built-in switches of a defective build. They are the one change that Tiny Jukebox itself needs;
the two versions with
crashandhangcan be made from a finished archive (deploy/install/test/defective_slot.sh). - The driver: a class per container (run a command in it, kill, start, connect and disconnect a network), the
git server (create user, token, repository and the secret
SIGNING_KEY; publish a release through its interface; wait for a run of the workflow), the developer (clone, bump, changelog, push), the builds, the saved states, and what a user does on a box and on the page of the library through their REST interfaces. - The scenarios of section 9.5, one test each, named after its number. The tests of
tests/syncwith the same numbers show what each one asks of the real application. - The workflow for Gitea, which calls the scripts of
scripts/ci/like the one for GitHub. Two steps differ: the checkout is done withgit fetch(the runner has no internet for an action), and the release is created through the interface of Gitea. - Stage 2, if the release chain is what changed: the repository on GitHub with the key of the releases as its
secret
SIGNING_KEY, and a token for the library that may only read it.
What it needs on the PC: Docker with Compose, at least 5 GB free on its disk, and internet for the first build of the images. What it showed the first time, to be kept in mind:
- Gitea is similar to GitHub, not equal: the query for releases has two variants (
releases.kind:github,gitea), which differ in the conditional request and in the download of a file of a private repository. - A scenario waits for a state, never for a time.
- Without
BUILDX_NO_DEFAULT_ATTESTATIONS=1every build gets a new image id, and all containers are created again. - The git server starts empty with every run. A plain git server would not do; it knows neither releases nor a workflow.
10. Tests¶
tests/sync/ runs a library and boxes in one process over HTTPS (bed.py), with the scenario numbers of
section 9.5:
| File | Content |
|---|---|
test_library_and_box.py |
first start with a library, synchronization, conflicts, albums on demand, space, access |
test_fill.py |
first filling, and a box with the same data afterwards |
test_slots.py |
health check and reports with the real slot scripts on a folder; the fallback version of the first installation, with small signed archives made by the test; the update chain with a real, signed slot archive built for the PC (skipped if it cannot be built) |
test_recovery.py |
a damaged journal: complete lines kept, lost numbers as recovery_gap, the library goes on past them and counts no listening |
test_diagnostics.py |
what a box logs reaches the library, only admins see it, an error at once; the report of a box that cannot start, from the start script, until the box runs again |
test_recovery_mode.py |
recovery mode in the fallback slot, with a stand-in for the git server: installs a version and restarts into it, never starts the application, waits without a restart loop, passes over versions the box went back from, waits with updates by hand |
test_unlink.py |
a box leaves its library: what it keeps, what is lost, linked again, the way out of a library that is too old, the request of the page |
test_statistics.py |
journal and statistics: of all boxes, of one box in the library, of a box on its own page with and without a library |
test_broken.py |
broken files: found by the library, reported by a box, dropped |
test_covers.py |
titles and covers of albums and covers of stations: imported with tinyjukebox-import-covers, synchronized between library and boxes; the stored covers of 0.31 moved into the images folder; a box and a library before 0.32; tracks and playing time of an album that a box has not fetched |
test_releases.py |
the token for the releases, set on the page of the library; a public repository asked without one; how many versions the archive keeps |
test_box_releases.py |
a box without a library and the releases, with a stand-in for the git server: looking, the waiting time, releases in a row, a withdrawn one, a wrong signature, an interrupted download, the modes, a token, the first version |
test_package.py |
the scripts of the package for the NAS, run as the Package Center runs them: installation with the answers of the dialog, start, login, stop |
test_install.py |
what the library hands out for a new box without credentials – installer, slot, signature of the newest usable version with an installer – and nothing else; the pin of the line; the installer fetched with a release and brought by the package |
Beside them: tests/unit/test_installer.py runs the installer with --root on a folder that stands for a fresh
Raspberry Pi OS (the files it writes, a second run, the block of the playbook, another system, library, token,
--no-slots); tests/unit/test_data_folder.py starts library and box with one folder (section 3.9).
The slot scripts themselves – installing, switching, power cuts – were proven in the test bed (section 9), which
is not kept; beyond what test_slots.py and the first check below run, they have no test now.
Checks outside pytest:
| Script | What it does |
|---|---|
deploy/install/test/run.sh SLOT-ARCHIVE (by hand) |
runs the installer of the clone on a stand-in for a box – a container with Debian trixie for aarch64 and systemd – with the real slot archive: packages, sudoers rules, firmware, the user admin, the box starts its slot under systemd and passes stage 1; a second run of the installer changes nothing; a version that crashes and one that hangs are left, and the version before runs again. Takes a while emulated – the installer installs the packages of a box; no real box is touched |
scripts/ci/test_image.sh (in the workflow, on every push) |
builds the image from the wheel of the build, starts a library and a box linked to it and waits until the box registers |
scripts/pages/check.sh [FOLDER] |
starts a library and a box without hardware on scratch data and drives both pages with headless Chromium: every tab, the version card and the LED sequence with given data, the address of the library entered on the page of the box, a tag placed in the browser, statistics, token, renaming a box. Screenshots and every text of each tab go to FOLDER |