No description
  • Python 84.2%
  • Shell 14.7%
  • Dockerfile 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Olivier Cortès 95bbffe448 fix(installer): restart: always pour que la pile reparte après un restart de Docker
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>
2026-11-10 22:43:31 +01:00
.githooks chore(license): release under GPL-3.0-or-later 2026-09-03 06:06:18 +02:00
resources/git chore(license): release under GPL-3.0-or-later 2026-09-03 06:06:18 +02:00
src/rcp_queue_manager feat(windows): keep long tracks out of the day window 2026-11-08 15:33:28 +01:00
tests feat(installer): bascule queue-manager sur la pile unifiée rcp-runtime 2026-11-10 15:21:44 +01:00
.gitignore chore(history): import released 0.1.0 source snapshot 2026-09-03 05:08:13 +02:00
.gitlab-ci.yml chore(license): release under GPL-3.0-or-later 2026-09-03 06:06:18 +02:00
AGENTS.md chore(license): release under GPL-3.0-or-later 2026-09-03 06:06:18 +02:00
CHANGELOG.md release: queue-manager 0.5.4 2026-11-10 16:13:02 +01:00
Dockerfile feat(installer): decouple runtime image from daemon source 2026-11-08 16:43:43 +01:00
install-rcp-queue-manager.sh fix(installer): le conteneur de snapshot regagnait le checkout monté 2026-11-10 16:13:01 +01:00
LICENSE chore(license): release under GPL-3.0-or-later 2026-09-03 06:06:18 +02:00
patch_compose.py fix(installer): restart: always pour que la pile reparte après un restart de Docker 2026-11-10 22:43:31 +01:00
README.md release: queue-manager 0.5.0 2026-11-08 16:43:43 +01:00
source_sha.py feat(installer): bascule queue-manager sur la pile unifiée rcp-runtime 2026-11-10 15:21:44 +01:00
VERSION release: queue-manager 0.5.4 2026-11-10 16:13:02 +01:00

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:users on 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 zoneinfo conversion, 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:

  1. connects to MPD;
  2. forces:
    • consume=1;
    • random=0;
    • repeat=0;
    • single=0;
  3. removes queue entries strictly before the current song;
  4. 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);
  5. extends the future queue to at least 48 h;
  6. writes /state/ready;
  7. 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.

  1. 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 owning n identified tracks can contribute at most min(n, ceil(T/A)) distinct tracks during T days. 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.
  2. 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:

  1. AlbumArtist when present and not a Various Artists equivalent;
  2. 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:

  1. strict dynamic track + artist + Composer;
  2. Composer at 50%;
  3. Composer disabled;
  4. track at 90%;
  5. track at 80%;
  6. track 80% + artist 90%;
  7. track 80% + artist 80%;
  8. 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 (ashuffle or managed queue-manager);
  • the existing radio-pelton.service whole-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-manager with --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.