- Python 84.2%
- Shell 14.7%
- Dockerfile 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Le bloc compose managé émettait restart: unless-stopped : après un restart du démon Docker (test de reboot de Terry), les conteneurs arrêtés explicitement restaient arrêtés et la pile RCP ne reprenait pas. Le template du patcher émet désormais restart: always, aligné sur le compose.yaml de production corrigé le 2026-11-10. Assisted-by: Félin Capiteux <felin@lodlp.net> |
||
| .githooks | ||
| resources/git | ||
| src/rcp_queue_manager | ||
| tests | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| Dockerfile | ||
| install-rcp-queue-manager.sh | ||
| LICENSE | ||
| patch_compose.py | ||
| README.md | ||
| source_sha.py | ||
| VERSION | ||
RCP-queue-manager 0.5.0
VERSION in the canonical Git checkout is the operational source of truth for
the live Python daemon. The checkout is mounted read-only into the container;
the runtime image is only an execution environment (Python, dependencies,
bundled ashuffle) identified by its env-sha256 label. Source-only releases
advance the live code without rebuilding the image; an image rebuild happens
only when the runtime environment (Dockerfile/build args) changes. The daemon
exposes the SHA-256 of the mounted source for observability.
Radio Pelton queue policy daemon for MPD.
Architecture
This release replaces the long-lived ashuffle Compose service with one
long-lived Python queue-manager container.
ashuffle 3.14.10 (release commit 56f20e3) remains part of the design,
but only as a binary bundled inside the queue-manager image. The Docker build
refuses the tag if it does not resolve to that commit. ashuffle runs for a few
seconds when a refill is needed, then exits.
Runtime isolation:
- Alpine Linux 3.24.1;
- UID/GID
1000:100(olive:userson Terry); network_mode: none;- MPD access only through
/run/mpd-socket/mpd.sock; - historyd mounted read-only;
- MPD gzip database mounted read-only at
/mpd-data/database; - writable state limited to
./queue-manager:/state; - read-only root filesystem;
cap_drop: ALL;no-new-privileges.
The image is multi-stage: ashuffle is compiled from the pinned git tag
v3.14.10; compiler/git/Meson/Ninja do not remain in the runtime image.
Queue policy
Normal operation
0.3.x keeps the 0.2 track-first selector but derives its long-term cooldowns
from the actual catalogue instead of hard-coding 14 days / 24 h. A valid
/state/cooldown-policy.json provides:
- exact-track cooldown chosen by capacity/stress analysis;
- primary Artist/AlbumArtist cooldown with a hard policy floor of 7 days;
- Composer cooldown 24 h for the first dynamic policy;
- refill trigger: future queue < 20 h;
- refill target: 24 h;
- maximum refill chunk: 4 h.
The policy is recalculated at most once per 7 days in a separate child process. The daemon keeps using the last valid policy while analysis runs. If no valid dynamic policy exists, it fails safely to the exact 0.2.0 policy (14-day track cooldown, 24-hour artist/composer windows) until analysis succeeds.
The candidate universe comes from MPD listall(), then is restricted to the
explicit audio-container allowlist (.flac, .mp3, .m4a, .wma, .opus,
.wav, .aif, .aiff, .ogg, .mpc) before any rotation policy is applied.
Existing future queue entries outside that allowlist are removed during bootstrap
and reconciliation; the currently playing item is never interrupted by this cleanup.
For metadata, the daemon reads MPD's small gzip database through a read-only bind and builds a
reconstructible RAM index. On Terry this currently covers ~75k live URI and
avoids thousands of 20–30 ms find(file) calls. If the gzip database does not
cover every live URI, queue-manager refuses to trust that index and falls back
to the correctness-preserving MPD lazy lookup path.
Each chunk is selected under the anti-repeat rules, passed to ashuffle for
one-shot random ordering, then the real MPD queue order is read again before
the next chunk is selected. MPD idle() still drives queue reconciliation;
there is no periodic queue polling loop.
Day/night editorial windows
Tracks longer than RCP_LONG_TRACK_MIN_SECONDS (default 600 s: DJ mixes,
full-length discs, long ambiances) never start inside the local day window
[RCP_DAY_START_HOUR, RCP_NIGHT_START_HOUR) (default 06:00-22:00
Europe/Paris). The night keeps a uniform draw with longs allowed and no
quota.
- The daemon stays entirely UTC internally; the window clock is an explicit
zoneinfoconversion, never the container locale. - The duration filter is a hard editorial constraint: it never enters the
relaxation scale, and a day chunk unable to fill without long tracks fails
loudly (
ERROR+RuntimeError) instead of degrading silently. - Refill chunks are clamped to the distance between the projected chunk start and the next window boundary, so a chunk never crosses 06:00/22:00 and DST nights decompose naturally into unequal aligned chunks (7 h in March, 9 h in October).
- Bootstrap removes inherited future longs that would start inside the day window, so the rule is effective immediately instead of waiting for the pre-upgrade queue to drain.
Bootstrap
Every queue-manager process start:
- connects to MPD;
- forces:
consume=1;random=0;repeat=0;single=0;
- removes queue entries strictly before the current song;
- keeps the current song and existing future queue, except inherited future longs that would start inside the day window (removed so the editorial windows are effective immediately);
- extends the future queue to at least 48 h;
- writes
/state/ready; - only then becomes healthy.
The first ashuffle one-shot may resume playback while the 48 h bootstrap is being assembled, preserving the previous continuous-radio behavior. The queue-manager nevertheless stays unhealthy until the full >=48 h future queue has been verified.
Weekly dynamic cooldown analysis
The analyzer is also directly executable for audit:
PYTHONPATH=src python3 -m rcp_queue_manager.policy
It deliberately separates two questions.
- Combinatorial capacity: from the real distribution of tracks per primary
artist it computes the non-dominated track/artist capacity frontier. For an
artist window
A, an artist owningnidentified tracks can contribute at mostmin(n, ceil(T/A))distinct tracks duringTdays. Playable tracks without a usable primary artist remain part of the title pool but contribute no artist constraint: their missing metadata cannot legitimately be treated as one shared pseudo-artist. Summing both populations gives the theoretical Pareto frontier without pretending that every catalogue shape is uniform. - Operational comfort: candidate points are then tested with deterministic Monte-Carlo runs reproducing track-first selection, 4-hour chunks, ashuffle ordering, artist and Composer exclusion, and a 48-hour stress horizon. The cycle models the day/night windows: day chunks cannot select long tracks while night chunks use the whole catalogue, and the capacity bound checks the day share of the guarded demand against short tracks only. A candidate must retain at least 20% of the eligible pool of its window for all configured seeds.
The guarded play rate is based on the maximum observed rolling 24-hour play count in the recent seven-day window, multiplied by the explicit 5% safety margin. This avoids inventing a midnight boundary in UTC or local time.
Selection is lexicographic: Artist cooldown must be at least seven days; within
that constraint the analyzer maximizes track cooldown, then maximizes artist
cooldown without reducing the selected track window. The complete inputs,
seeds, theoretical frontier, stress results and selected point are persisted in
cooldown-policy.json so the decision remains auditable.
A policy file is accepted only when its schema and scientific inputs match the current analyzer and its selected point still satisfies the artist floor and 20% reserve. A code change to those assumptions therefore invalidates an old policy rather than silently inheriting it.
Current Terry measurement (2026-09-03, informative only, never hard-coded):
live MPD URI 75,766
playable/selectable tracks 75,545
tracks without primary 5,130
invalid duration 221
primary artists 12,483
composers 3,846
guarded rate 382.20 tracks/day
theoretical point @ 7 d 97 d track / 7 d artist
operational selected point 66 d track / 7 d artist
minimum joint reserve 20.4752%
67 d / 7 d 19.6916% -> rejected
Tracks without a usable primary artist
A missing/unknown primary artist is a metadata limitation, not an audio validity failure. Since 0.3.1, such tracks remain eligible for uniform track-first selection. Exact-track cooldown always applies; Composer cooldown still applies when known; artist cooldown is skipped only because there is no reliable identity to compare. This prevents thousands of otherwise playable tracks from being silenced while quality-improver repairs metadata gradually.
The weekly analyzer models the same semantics explicitly, so these tracks increase title capacity without being falsely grouped under one synthetic "unknown artist" identity.
Track-first diversity and anti-repeat
Candidate sampling is track-first. The manager shuffles the MPD URI list and examines tracks directly. It never selects an Artist/AlbumArtist first and then a track from that identity. This makes the base probability uniform per track: a one-track artist and a 30-track artist no longer give their individual tracks different weights, and multi-artist collaborations do not gain extra entry points into the random selection.
A file whose projected playback time would violate the active dynamic track cooldown is rejected before candidate metadata work. The same projected-time logic applies to the dynamic primary-artist window. Historical paths that disappeared after metadata/filename repair are resolved conservatively against the complete RAM metadata index using normalized title plus artist overlap; MPD search is retained only when the read-only database index is unavailable.
Primary identity:
AlbumArtistwhen present and not aVarious Artistsequivalent;- otherwise
Artist.
Composer is constrained when MPD has a Composer tag. Missing Composer values
do not collapse into one pseudo-composer.
For historyd plays, Composer and primary identities are merged into a
reconstructible last_played RAM index. Current and renamed URI metadata are
resolved from the same read-only MPD database cache, so normal startup performs
no find(file) storm. historyd remains the canonical source of play history;
the RAM index is disposable.
Constraint relaxation
With a dynamic policy, relaxations are derived from the selected point rather than from unrelated fixed day counts:
- strict dynamic track + artist + Composer;
- Composer at 50%;
- Composer disabled;
- track at 90%;
- track at 80%;
- track 80% + artist 90%;
- track 80% + artist 80%;
- history constraints disabled as the final availability fallback.
Repeated use of a relaxation is an operational signal that the weekly policy was too aggressive or the catalogue changed materially. Every level is logged. If no dynamic policy is valid yet, the original 0.2 relaxation ladder is used unchanged.
Even when history must be relaxed, a single <=4 h chunk never deliberately contains the same primary identity twice, the same known Composer twice, or the same file twice. If the fully relaxed pass still produces a useful partial chunk, it is enqueued and the real MPD horizon is recomputed.
ashuffle one-shot
queue-manager chooses a policy-compliant set with one song per primary identity for the current chunk. It then runs:
ashuffle --only N --file - --no-check \
--tweak window-size=N \
--tweak play-on-startup=yes \
--tweak reconnect-timeout=15s
The MPD URIs are sent over stdin.
Because the pool size and ashuffle window size are both N, the first N
emissions are the complete candidate set exactly once, in random order.
Persistent files
/home/docker/radio-pelton/queue-manager/
├── RCP-queue-manager.log
├── RCP-queue-manager.log.1 ...
├── ready
├── state.json
└── cooldown-policy.json
The log rotates at 10 MiB with 5 backups.
Installer
Extract the bundle anywhere on Terry, then:
cd RCP-queue-manager
./install-rcp-queue-manager.sh
Build/test without changing production:
./install-rcp-queue-manager.sh --prepare
Non-interactive apply:
./install-rcp-queue-manager.sh --yes
The installer performs three phases.
1. Read-only production preflight
It checks:
- current Compose syntax;
- MPD service + Unix socket;
- historyd service + daily history;
- exactly one current queue policy (
ashuffleor managedqueue-manager); - the existing
radio-pelton.servicewhole-stack orchestration; - that MPD is running and healthy;
- that historyd is running;
- that the proposed Compose patch is valid.
2. Build and isolated tests
Before the old queue writer is stopped:
- builds the Alpine image, or reuses an image with matching source SHA;
- runs queue-manager's self-test;
- runs bundled
ashuffle --version; - records the exact ashuffle git commit used by the tag checkout;
- checks image labels;
- runs a read-only preflight against the real MPD socket and historyd data.
3. Transactional apply
Before MPD is changed, the installer:
- saves
compose.yaml; - saves the previous queue-manager build context, when present;
- stops only the previous queue writer (MPD keeps playing);
- snapshots the complete, now-quiescent MPD queue;
- snapshots current song position, elapsed time, playback state and consume/random/repeat/single options.
It then:
- installs the build context and Compose file atomically;
- starts only
queue-managerwith--no-deps; - waits up to 10 minutes for the >=48 h bootstrap;
- verifies
state.json; - removes the obsolete ashuffle container only after success.
It never runs docker compose down.
On failure, rollback restores Compose, the build context, the exact MPD queue / playback/options snapshot, and the old queue policy service.
The old ashuffle image/source tree is deliberately not deleted automatically: it costs no running RAM and remains useful for rollback/forensics.
Status
cd /home/docker/radio-pelton
docker compose ps queue-manager
docker compose logs --tail=100 queue-manager
cat queue-manager/state.json
Expected steady state:
future_hours ~= 20..24
Immediately after any queue-manager start:
bootstrap_complete = true
future_hours >= 48
After playback consumes the queue back below 20 h, the daemon returns to the normal 20 h -> 24 h policy.
Manual image checks
docker run --rm --network none \
radio-pelton-queue-manager:0.1.1 --self-test
docker run --rm --network none \
--entrypoint ashuffle \
radio-pelton-queue-manager:0.1.1 --version
docker run --rm --network none \
--entrypoint cat \
radio-pelton-queue-manager:0.1.1 \
/usr/local/share/ashuffle-source-commit
Git source checkout and source-only upgrades (>= 0.1.2)
The canonical queue-manager source is the Git checkout at:
/home/docker/radio-pelton/queue-manager-src
Compose bind-mounts that checkout read-only at /opt/rcp-queue-manager. The image
still contains a fallback source snapshot for isolated self-tests and rollback,
but production Python imports resolve through the read-only bind mount.
A source-only upgrade therefore does not rebuild the image:
cd /home/docker/radio-pelton/queue-manager-src
git fetch --tags origin
git pull --ff-only
PYTHONPATH=src python3 -m rcp_queue_manager.daemon --self-test
docker compose --project-directory /home/docker/radio-pelton \
-f /home/docker/radio-pelton/compose.yaml restart queue-manager
The daemon reads VERSION from the mounted checkout and records a canonical
SHA-256 of VERSION plus src/**/*.py in its startup log and state.json.
Runtime/dependency changes still require an image rebuild and a normal installer
run. The 0.3.0 transition itself adds the read-only /mpd-data bind and policy
environment, so upgrading from 0.2.x requires recreating only the
queue-manager container with the patched Compose. No new image dependency is
introduced.
License
RCP queue-manager is free software licensed under the GNU General Public License, version 3 or (at your option) any later version.
SPDX-License-Identifier: GPL-3.0-or-later
See LICENSE for the complete GNU GPL version 3 license text.