R7: extract the M3U regeneration into a shared sourced helper
run-playlist.sh and import-track.sh had the same beets->#EXTM3U block, but import-track.sh wrote the file non-atomically. Move it to pipeline/lib/m3u.sh as regen_m3u <name> <out>, sourced by both. The helper always writes atomically (temp + rename), so a reader like Navidrome never sees a partial file and a stray wrong-owner leftover .m3u8 is replaced without a chown. Returns 0 always (empty result is a warning), so it's safe as a standalone call under set -e. Verified: tests/test_m3u.py (atomic write with tracks, no file when empty; 44 tests green) plus a live regen against the real library (techno -> 229 tracks, no temp leftover). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,8 @@ source ${PIPELINE_DIR:-/app/pipeline}/lib/tag-guard.sh
|
||||
source ${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh
|
||||
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/mb-tags.sh
|
||||
source ${PIPELINE_DIR:-/app/pipeline}/lib/mb-tags.sh
|
||||
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/m3u.sh
|
||||
source ${PIPELINE_DIR:-/app/pipeline}/lib/m3u.sh
|
||||
|
||||
# ==== Parse arguments ====
|
||||
# Two modes:
|
||||
@@ -242,28 +244,8 @@ log "beets import finished with exit code $BEETS_EXIT"
|
||||
|
||||
# ==== Regenerate M3U if playlist was specified ====
|
||||
if [[ -n "$PLAYLIST_NAME" ]]; then
|
||||
mkdir -p "$PLAYLISTS_DIR"
|
||||
M3U_OUT="${PLAYLISTS_DIR}/${PLAYLIST_NAME}.m3u8"
|
||||
log "Regenerating M3U at $M3U_OUT"
|
||||
|
||||
BEET_OUTPUT=$(beet ls -f '$path' "grouping:${PLAYLIST_NAME}" 2>>"$LOG" || true)
|
||||
# grep -c '^' exits 1 on empty input, which would trip set -e and kill the
|
||||
# whole run silently right here. Count lines a way that's safe on no-match.
|
||||
if [[ -z "$BEET_OUTPUT" ]]; then
|
||||
TRACK_COUNT=0
|
||||
else
|
||||
TRACK_COUNT=$(printf '%s\n' "$BEET_OUTPUT" | wc -l)
|
||||
fi
|
||||
|
||||
if [[ "$TRACK_COUNT" -gt 0 ]]; then
|
||||
{
|
||||
echo "#EXTM3U"
|
||||
echo "$BEET_OUTPUT"
|
||||
} > "$M3U_OUT"
|
||||
log "M3U updated with $TRACK_COUNT tracks"
|
||||
else
|
||||
log "WARNING: no tracks found with grouping:$PLAYLIST_NAME — M3U not updated"
|
||||
fi
|
||||
log "Regenerating M3U for $PLAYLIST_NAME"
|
||||
regen_m3u "$PLAYLIST_NAME" "${PLAYLISTS_DIR}/${PLAYLIST_NAME}.m3u8"
|
||||
fi
|
||||
|
||||
# ==== Trigger Navidrome scan (through the share-health gate) ====
|
||||
|
||||
@@ -36,6 +36,8 @@ SLDL_BIN=${PIPELINE_DIR:-/app/pipeline}/sldl
|
||||
source ${PIPELINE_DIR:-/app/pipeline}/lib/tag-guard.sh
|
||||
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh
|
||||
source ${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh
|
||||
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/m3u.sh
|
||||
source ${PIPELINE_DIR:-/app/pipeline}/lib/m3u.sh
|
||||
|
||||
# ==== Setup ====
|
||||
mkdir -p "$LOG_DIR" "$DROPBOX" "$PLAYLISTS_DIR"
|
||||
@@ -159,36 +161,8 @@ log "Beets has $TAGGED_COUNT tracks tagged with grouping:$PLAYLIST_NAME"
|
||||
if [[ "$NO_M3U" == true ]]; then
|
||||
log "Skipping M3U generation (--no-m3u)"
|
||||
else
|
||||
M3U_OUT="${PLAYLISTS_DIR}/${PLAYLIST_NAME}.m3u8"
|
||||
log "Regenerating M3U at $M3U_OUT from beets library"
|
||||
|
||||
# Capture beet output to a variable first, then write to file.
|
||||
BEET_OUTPUT=$(beet ls -f '$path' "grouping:${PLAYLIST_NAME}" 2>>"$LOG" || true)
|
||||
# grep -c '^' exits 1 on empty input, which would trip set -e and kill the
|
||||
# whole run silently right here. Count lines a way that's safe on no-match.
|
||||
if [[ -z "$BEET_OUTPUT" ]]; then
|
||||
TRACK_COUNT=0
|
||||
else
|
||||
TRACK_COUNT=$(printf '%s\n' "$BEET_OUTPUT" | wc -l)
|
||||
fi
|
||||
|
||||
if [[ "$TRACK_COUNT" -gt 0 ]]; then
|
||||
# Write to a temp file in the same dir, then rename over the target.
|
||||
# rename(2) only needs write access on the directory, not on the file
|
||||
# being replaced -- so this also survives a stray wrong-owner leftover
|
||||
# M3U (e.g. one written by a previous non-containerized run) without
|
||||
# needing a host-side chown, and it's atomic for anything (Navidrome)
|
||||
# reading the file mid-write.
|
||||
M3U_TMP="${M3U_OUT}.tmp.$$"
|
||||
{
|
||||
echo "#EXTM3U"
|
||||
echo "$BEET_OUTPUT"
|
||||
} > "$M3U_TMP"
|
||||
mv "$M3U_TMP" "$M3U_OUT"
|
||||
log "M3U updated with $TRACK_COUNT tracks"
|
||||
else
|
||||
log "WARNING: beet list returned no tracks for grouping:$PLAYLIST_NAME — not updating M3U"
|
||||
fi
|
||||
log "Regenerating M3U for $PLAYLIST_NAME from beets library"
|
||||
regen_m3u "$PLAYLIST_NAME" "${PLAYLISTS_DIR}/${PLAYLIST_NAME}.m3u8"
|
||||
fi
|
||||
|
||||
log "=== Finished playlist run: $PLAYLIST_NAME ==="
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Sourced helper: regenerate one playlist's .m3u8 from the beets library.
|
||||
#
|
||||
# regen_m3u <playlist_name> <out_path>
|
||||
#
|
||||
# Writes atomically (temp file in the same dir, then rename over the target):
|
||||
# rename(2) is atomic for a reader like Navidrome, and needs write access only
|
||||
# on the directory, so it also replaces a stray wrong-owner leftover .m3u8 (e.g.
|
||||
# from a pre-container run) without a host-side chown.
|
||||
#
|
||||
# Relies on the caller's log() function and $LOG. Always returns 0 (an empty
|
||||
# result is a warning, not a failure), so it is safe to call as a standalone
|
||||
# statement under set -euo pipefail.
|
||||
regen_m3u() {
|
||||
local playlist_name="$1"
|
||||
local out="$2"
|
||||
mkdir -p "$(dirname "$out")"
|
||||
|
||||
local beet_output
|
||||
# || true: beet ls exits non-zero on no match, which would trip set -e.
|
||||
beet_output=$(beet ls -f '$path' "grouping:${playlist_name}" 2>>"$LOG" || true)
|
||||
local track_count=0
|
||||
[[ -n "$beet_output" ]] && track_count=$(printf '%s\n' "$beet_output" | wc -l)
|
||||
|
||||
if [[ "$track_count" -gt 0 ]]; then
|
||||
local tmp="${out}.tmp.$$"
|
||||
{
|
||||
echo "#EXTM3U"
|
||||
echo "$beet_output"
|
||||
} > "$tmp"
|
||||
mv "$tmp" "$out"
|
||||
log "M3U updated with $track_count tracks"
|
||||
else
|
||||
log "WARNING: beet list returned no tracks for grouping:${playlist_name} — not updating M3U"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""regen_m3u (pipeline/lib/m3u.sh): writes #EXTM3U + track paths atomically,
|
||||
and leaves the file untouched when the playlist has no tracks. `beet` is stubbed
|
||||
so no real library is needed."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
M3U_SH = REPO / "pipeline" / "lib" / "m3u.sh"
|
||||
|
||||
|
||||
def _run(work: Path, playlist: str, out: Path) -> subprocess.CompletedProcess:
|
||||
script = r"""
|
||||
set -euo pipefail
|
||||
export LOG="{work}/log.txt"; : > "$LOG"
|
||||
log() {{ echo "$*" >> "$LOG"; }}
|
||||
# stub beet: two paths only for grouping:techno, nothing otherwise
|
||||
beet() {{ case "$*" in *grouping:techno*) printf '/music/a.flac\n/music/b.flac\n';; esac; }}
|
||||
source "{m3u}"
|
||||
regen_m3u "{playlist}" "{out}"
|
||||
""".format(work=work, m3u=M3U_SH, playlist=playlist, out=out)
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_writes_m3u_with_tracks(tmp_path):
|
||||
out = tmp_path / "sub" / "techno.m3u8"
|
||||
proc = _run(tmp_path, "techno", out)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert out.read_text() == "#EXTM3U\n/music/a.flac\n/music/b.flac\n"
|
||||
# atomic write leaves no temp file behind
|
||||
assert not list(out.parent.glob("*.tmp.*"))
|
||||
|
||||
|
||||
def test_no_tracks_leaves_no_file(tmp_path):
|
||||
out = tmp_path / "empty.m3u8"
|
||||
proc = _run(tmp_path, "empty", out)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert not out.exists()
|
||||
assert "no tracks" in (tmp_path / "log.txt").read_text()
|
||||
Reference in New Issue
Block a user