Stage 0: migrate pipeline scripts from /opt/sldl, scaffold repo

Moves all ~30 pipeline scripts, configs, and the vendored sldl binary into
this repo (source /opt/sldl left untouched). Removes all docker exec/docker
compose dependencies now that beets and sldl run in-process/as a subprocess
of this container instead of via soulbeet/on-demand sldl containers.
Replaces hardcoded host paths, Navidrome credentials, and Spotify credential
sourcing with env-var-driven paths and shared credential loaders. Adds
Dockerfile, entrypoint.sh, requirements.txt, docker-compose.snippet.yml, and
the initial app DB schema.
This commit is contained in:
andrew
2026-07-08 13:10:55 -06:00
commit 68cb007e4c
48 changed files with 9261 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# python:3.12-slim-bookworm (glibc, not Alpine): numpy/beets/mutagen need
# manylinux wheels — Alpine's musl libc forces a from-source numpy build and
# is a known source of breakage for this exact stack.
FROM python:3.12-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
FROM python:3.12-slim-bookworm AS runtime
# libicu72: sldl (.NET self-contained publish) runtime dependency.
# ffmpeg: convert-m4a.sh. libchromaprint-tools: fpcalc for fingerprinting.
RUN apt-get update && apt-get install -y --no-install-recommends \
libicu72 \
ca-certificates \
ffmpeg \
libchromaprint-tools \
flac \
id3v2 \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN groupadd -g 1000 alembic && \
useradd -u 1000 -g 1000 -m -s /usr/sbin/nologin alembic
WORKDIR /app
COPY app/ /app/app/
COPY pipeline/ /app/pipeline/
COPY schema/ /app/schema/
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh /app/pipeline/sldl /app/pipeline/bin/*.sh /app/pipeline/lib/*.sh /app/pipeline/configs/regen.sh && \
chown -R alembic:alembic /app
USER alembic
ENV ALEMBIC_CONFIG_DIR=/config \
MUSIC_DATA_DIR=/data/music \
PIPELINE_DIR=/app/pipeline \
ALEMBIC_PORT=8420
EXPOSE 8420
ENTRYPOINT ["/app/entrypoint.sh"]
+52
View File
@@ -0,0 +1,52 @@
# Paste this block into /home/andrew/booksandtunes/docker-compose.yml.
#
# Also required, one-time, in that same file:
# 1. gluetun's environment: add/extend FIREWALL_OUTBOUND_SUBNETS to include
# the bridge network subnet (default 172.19.0.0/16 in this compose
# project — confirm with `docker network inspect booksandtunes_default`)
# so alembic can still reach navidrome:4533 etc:
# FIREWALL_OUTBOUND_SUBNETS=192.168.0.0/24,172.19.0.0/16
# 2. gluetun's ports: add the alembic web port (default 8420, verify free
# on the host first):
# - 8420:8420
# 3. Pin the bridge subnet so it can't silently drift on network recreate:
# networks:
# default:
# ipam:
# config:
# - subnet: 172.19.0.0/16
# 4. Remove the old on-demand `sldl:` service block entirely (superseded by
# the vendored binary running as an alembic subprocess) once cutover to
# alembic scheduling is complete (Migration Stage 3 in docs/MIGRATION.md).
# 5. Remove the `soulbeet:` service block once alembic has feature parity
# (Migration Stage 4).
#
# Pangolin: point the new route at gluetun:8420, NOT alembic:8420 — alembic
# has no independent container DNS identity once it rides gluetun's netns.
alembic:
image: git.kretzer.club/andrew/alembic:latest
container_name: alembic
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
labels:
# Manual pull/up only — an auto-recreate mid-scheduled-job is exactly
# the kind of surprise this project's "no unnecessary automation"
# preference wants to avoid, and it would also orphan the
# network_mode: service:gluetun reference the same way gluetun's own
# watchtower exclusion comment (above, in the gluetun service) explains.
- "com.centurylinklabs.watchtower.enable=false"
volumes:
- ${MUSIC_DATA_DIR:-/mnt/qnap/Music}:/data/music:rw
- ${ALEMBIC_CONFIG_DIR:-/configs/alembicconfig}:/config:rw
secrets:
- alembic_master_key
restart: unless-stopped
# Add alongside the existing top-level `secrets:` block (create one if this
# compose file doesn't have one yet):
# secrets:
# alembic_master_key:
# file: /configs/alembicconfig/master.key
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# entrypoint.sh — seed first-run instance state into the config volume, then
# start the app. The image itself is code-only and stateless/rebuildable;
# everything mutable lives under $ALEMBIC_CONFIG_DIR (see docs/ARCHITECTURE.md).
set -euo pipefail
ALEMBIC_CONFIG_DIR="${ALEMBIC_CONFIG_DIR:-/config}"
PIPELINE_DIR="${PIPELINE_DIR:-/app/pipeline}"
mkdir -p \
"$ALEMBIC_CONFIG_DIR/beets/data" \
"$ALEMBIC_CONFIG_DIR/pipeline" \
"$ALEMBIC_CONFIG_DIR/logs"
seed_if_absent() {
local src="$1" dst="$2"
if [[ ! -f "$dst" ]]; then
cp "$src" "$dst"
echo "[entrypoint] seeded $dst from $(basename "$src")"
fi
}
seed_if_absent "$PIPELINE_DIR/beets/config.yaml.example" "$ALEMBIC_CONFIG_DIR/beets/config.yaml"
seed_if_absent "$PIPELINE_DIR/configs/artist-canonical.list.example" "$ALEMBIC_CONFIG_DIR/pipeline/artist-canonical.list"
seed_if_absent "$PIPELINE_DIR/configs/djmix-albums.txt.example" "$ALEMBIC_CONFIG_DIR/pipeline/djmix-albums.txt"
seed_if_absent "$PIPELINE_DIR/configs/genres-whitelist.txt.example" "$ALEMBIC_CONFIG_DIR/pipeline/genres-whitelist.txt"
export BEETSDIR="$ALEMBIC_CONFIG_DIR/beets"
exec uvicorn app.main:app --host 0.0.0.0 --port "${ALEMBIC_PORT:-8420}"
+122
View File
@@ -0,0 +1,122 @@
# Beets configuration for Soulbeet
# Container paths: /downloads = raw files from slskd, /music = organized library
directory: /music
library: /data/beets_library.db
# Import behavior
import:
move: true # move files from /downloads to /music (don't leave duplicates)
copy: false
write: true # write tags back to the files
autotag: true # auto-identify via MusicBrainz
quiet: no # interactive by default; scripts pass -q explicitly when needed
quiet_fallback: asis # let soulseek rips through with their own tags — upstream scripts
# quarantine files that are actually missing artist/title via tag-guard.sh
# before beets even sees them
duplicate_action: keep # always import; downstream dedup-library.sh removes only true
# (same albumartist+album+title) duplicates. This stops beets from
# rejecting e.g. "Goodbye to a World" on a Live album because the
# studio version is already in the library.
log: /data/beets_import.log
incremental: false
delete: false
resume: no
group_albums: no # don't group — treat each file as its own import (singleton-friendly)
timid: no
from_scratch: no
singletons: yes # default to singleton mode when ambiguous
bell: no
# File organization paths (relative to `directory`)
# We import everything as singletons (`beet import -s`), so the singleton paths
# below are what most files land at. Use $albumartist (not $artist) so multi-
# artist tracks like "Danny Brown; Kendrick Lamar; Ab-Soul; Earl Sweatshirt"
# land under the primary artist's folder, matching Navidrome's album grouping.
paths:
default: "%the{$albumartist}/$album%aunique{}/$track $title"
singleton: "%the{$albumartist}/$album/$title"
comp: "Compilations/$album%aunique{}/$track $title"
albumtype_soundtrack: "Soundtracks/$album/$track $title"
singleton:album:: "%the{$albumartist}/Singles/$title"
# Filename character handling
replace:
'[\\/]': _
'^\.': _
'[\x00-\x1f]': _
'[<>:"\?\*\|]': _
'\.$': _
'\s+$': ''
'^\s+': ''
'^-': _
# Keep only music files, ignore cruft
ignore:
- .*
- "*~"
- System Volume Information
- lost+found
- Thumbs.db
- desktop.ini
- "*.nfo"
- "*.txt"
- "*.m3u"
- "*.sfv"
# Plugins (only ones that work with soulbeet's bundled dependencies)
# NOTE: 'scrub' is intentionally excluded because it strips non-standard tags
# like GROUPING that we use for playlist tracking.
plugins:
- fetchart
- embedart
- duplicates
- the
- edit
# NOTE: lastgenre plugin needs pylast which isn't in soulbeet's distroless
# image. We do the same job from the host via /opt/sldl/lib/lastfm-genre.py
# using mutagen + Last.fm REST API directly.
# Preserve the GROUPING tag that run-playlist.sh sets before beets imports.
# This tells beets to keep our custom tag as a library field.
item_fields:
grouping: grouping
# Plugin config
fetchart:
auto: yes
minwidth: 500
# maximum_height removed (needs ImageMagick/PIL)
# enforce_ratio removed (needs ImageMagick/PIL)
sources:
- filesystem
- coverart
- itunes
- albumart
- amazon
embedart:
auto: yes
# maxwidth and compare_threshold require ImageMagick/PIL which isn't available
# Leaving them off means embedart uses art as-is without resizing/comparison
# MusicBrainz search behavior
match:
strong_rec_thresh: 0.15 # auto-accept good matches
medium_rec_thresh: 0.35 # prompt for decent matches
rec_gap_thresh: 0.25
max_rec:
missing_tracks: medium
unmatched_tracks: medium
preferred:
countries: ["CA", "US", "GB", "XW"]
media: ["Digital Media", "CD"]
original_year: yes
# Threading / UI
ui:
color: yes
# Musicbrainz
musicbrainz:
searchlimit: 10
+280
View File
@@ -0,0 +1,280 @@
#!/bin/bash
# import-track.sh
#
# Imports files from ${MUSIC_DATA_DIR:-/data/music}/import-me/ into your Navidrome library,
# optionally tagging them with a playlist grouping.
#
# Drop files into ${MUSIC_DATA_DIR:-/data/music}/import-me/ (via SMB, web upload, copy, etc.)
# then run this script to import them.
#
# Usage:
# ./import-track.sh [playlist_name]
# ./import-track.sh <file_or_subdir> [playlist_name]
#
# Examples:
# ./import-track.sh # import everything in import-me/ (no playlist)
# ./import-track.sh modular # import everything, tag all as 'modular'
# ./import-track.sh my-track.flac # import just one file, no playlist
# ./import-track.sh my-track.flac modular # import one file, tag as 'modular'
# ./import-track.sh album_folder botanica # import folder, tag all as 'botanica'
#
# After successful import, files are moved out of import-me/ by beets.
set -euo pipefail
# Commands that are allowed to exit non-zero must be wrapped explicitly via
# `cmd && X=0 || X=$?` or `|| true`. Everything else aborts the import.
# ==== Paths ====
IMPORT_ROOT=${MUSIC_DATA_DIR:-/data/music}/import-me
DROPBOX_BASE=${MUSIC_DATA_DIR:-/data/music}/sldl-dropbox
DOWNLOADS=${MUSIC_DATA_DIR:-/data/music}/downloads
PLAYLISTS_DIR=${MUSIC_DATA_DIR:-/data/music}/playlists
QUARANTINE_DIR=${MUSIC_DATA_DIR:-/data/music}/Songs/untagged
LOG_DIR=${ALEMBIC_CONFIG_DIR:-/config}/logs
LOG=${LOG_DIR}/manual-import-$(date +%Y%m%d).log
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/tag-guard.sh
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/mb-tags.sh
source ${PIPELINE_DIR:-/app/pipeline}/lib/mb-tags.sh
# ==== Parse arguments ====
# Two modes:
# 1. ./script.sh -> import all files in import-me/ root
# 2. ./script.sh <playlist> -> import all files in import-me/ root with playlist tag
# 3. ./script.sh <file> -> import specific file (no playlist)
# 4. ./script.sh <file> <playlist> -> import specific file with playlist tag
# Distinguish by checking if $1 is a valid path inside IMPORT_ROOT
mkdir -p "$IMPORT_ROOT" "$LOG_DIR"
if [[ $# -eq 0 ]]; then
INPUT=""
PLAYLIST_NAME=""
elif [[ $# -eq 1 ]]; then
# Could be either a playlist name OR a file path
if [[ -e "${IMPORT_ROOT}/$1" ]]; then
INPUT="$1"
PLAYLIST_NAME=""
else
INPUT=""
PLAYLIST_NAME="$1"
fi
elif [[ $# -eq 2 ]]; then
INPUT="$1"
PLAYLIST_NAME="$2"
else
cat <<EOF
Usage: $0 [file_or_subdir] [playlist_name]
Place files in $IMPORT_ROOT/ first, then run this script.
Examples:
$0 # import everything in import-me/
$0 modular # import everything, tag as 'modular'
$0 my-track.flac # import one file, no playlist
$0 my-track.flac modular # import one file, tag as 'modular'
$0 album_folder botanica # import folder, tag as 'botanica'
EOF
exit 1
fi
log() {
echo "[$(date -Iseconds)] $*" | tee -a "$LOG"
}
log "=== Starting manual import ==="
log "Input: ${INPUT:-<all in import-me/>}"
log "Playlist: ${PLAYLIST_NAME:-<none>}"
# Resolve source path
if [[ -n "$INPUT" ]]; then
SOURCE="${IMPORT_ROOT}/${INPUT}"
if [[ ! -e "$SOURCE" ]]; then
log "ERROR: File or directory not found in import-me: $INPUT"
log "Available items:"
find "$IMPORT_ROOT" -mindepth 1 -maxdepth 1 -printf ' %f\n' | tee -a "$LOG"
exit 1
fi
else
SOURCE="$IMPORT_ROOT"
# Check there's actually something to import
if [[ -z "$(find "$IMPORT_ROOT" -type f \( -name "*.flac" -o -name "*.mp3" -o -name "*.wav" -o -name "*.m4a" \) 2>/dev/null)" ]]; then
log "ERROR: No audio files found in $IMPORT_ROOT"
log "Drop .flac, .mp3, .wav, or .m4a files into that folder first."
exit 1
fi
fi
# ==== Determine destination ====
if [[ -n "$PLAYLIST_NAME" ]]; then
DEST_DIR="${DROPBOX_BASE}/${PLAYLIST_NAME}"
else
DEST_DIR="$DOWNLOADS"
fi
mkdir -p "$DEST_DIR"
# ==== Convert WAV to FLAC (WAV doesn't support proper tagging) ====
# Also parse "Artist - Title.wav" filename pattern to set basic FLAC tags,
# since WAV files typically have no embedded metadata.
log "Converting any WAV files to FLAC"
WAV_COUNT=0
while IFS= read -r -d '' wavfile; do
flacfile="${wavfile%.wav}.flac"
if flac --silent --best --delete-input-file "$wavfile" -o "$flacfile" 2>>"$LOG"; then
# Parse "Artist - Title.flac" from filename and set tags
basename_noext=$(basename "$flacfile" .flac)
if [[ "$basename_noext" == *" - "* ]]; then
ARTIST="${basename_noext% - *}"
TITLE="${basename_noext#* - }"
metaflac \
--set-tag="ARTIST=${ARTIST}" \
--set-tag="TITLE=${TITLE}" \
--set-tag="ALBUM=${TITLE}" \
"$flacfile" 2>>"$LOG"
log "Converted + tagged: $(basename "$flacfile") (Artist: $ARTIST, Title: $TITLE)"
else
log "Converted: $(basename "$flacfile") — WARNING: couldn't parse artist/title from filename"
fi
WAV_COUNT=$((WAV_COUNT + 1))
else
log "WARNING: Failed to convert $wavfile — leaving as WAV (will be untagged)"
fi
done < <(find "$SOURCE" -name "*.wav" -type f -print0 2>/dev/null)
[[ $WAV_COUNT -gt 0 ]] && log "Converted $WAV_COUNT WAV files to FLAC"
# If the user pointed us at a single .wav, that path no longer exists after
# conversion (flac --delete-input-file) — repoint SOURCE at the converted
# .flac so the tag checks, GROUPING, and move below operate on the real file.
if [[ "$SOURCE" == *.wav && ! -e "$SOURCE" && -e "${SOURCE%.wav}.flac" ]]; then
SOURCE="${SOURCE%.wav}.flac"
log "Single-file WAV was converted; continuing with $(basename "$SOURCE")"
fi
# ==== Quarantine files with missing essential tags ====
log "Checking tags on audio files in $SOURCE"
QUARANTINED=$(quarantine_untagged "$SOURCE" "$QUARANTINE_DIR" "$LOG" || echo 0)
if [[ "$QUARANTINED" -gt 0 ]]; then
log "Quarantined $QUARANTINED file(s) to $QUARANTINE_DIR — fix their tags and drop them back into $IMPORT_ROOT to retry"
fi
# ==== Set ALBUMARTIST fallback so Navidrome doesn't coin ghost combined artists ====
AA_SET=$(set_albumartist_fallback "$SOURCE" "$LOG" || echo 0)
[[ "$AA_SET" -gt 0 ]] && log "Set ALBUMARTIST fallback on $AA_SET file(s) (primary artist before first separator)"
# Bail out if nothing survived the tag check
if [[ -z "$(find "$SOURCE" -type f \( -iname "*.flac" -o -iname "*.mp3" -o -iname "*.m4a" \) 2>/dev/null)" ]]; then
log "ERROR: no tagged audio files left to import after quarantine"
exit 1
fi
# ==== Tag files with playlist name BEFORE moving ====
# Tagging in place in import-me/ ensures we only touch the files being
# imported in this run, not any leftover files from previous runs.
if [[ -n "$PLAYLIST_NAME" ]]; then
log "Tagging files in import-me/ with GROUPING=$PLAYLIST_NAME"
FLAC_COUNT=0
MP3_COUNT=0
# Scope of files to tag (dir or single file — find handles both)
TAG_SCOPE="$SOURCE"
while IFS= read -r -d '' file; do
metaflac --remove-tag=GROUPING --set-tag="GROUPING=${PLAYLIST_NAME}" "$file" 2>>"$LOG" || true
FLAC_COUNT=$((FLAC_COUNT + 1))
done < <(find "$TAG_SCOPE" -name "*.flac" -type f -print0 2>/dev/null)
while IFS= read -r -d '' file; do
id3v2 --TIT1 "${PLAYLIST_NAME}" "$file" 2>>"$LOG" || true
MP3_COUNT=$((MP3_COUNT + 1))
done < <(find "$TAG_SCOPE" -name "*.mp3" -type f -print0 2>/dev/null)
log "Tagged $FLAC_COUNT FLAC and $MP3_COUNT MP3 files"
fi
# ==== Move files from import-me to destination ====
log "Moving from $SOURCE to $DEST_DIR"
if [[ -d "$SOURCE" ]] && [[ "$SOURCE" == "$IMPORT_ROOT" ]]; then
# Moving all contents of import-me/ (but not the folder itself)
shopt -s dotglob nullglob
for item in "$SOURCE"/*; do
[[ -e "$item" ]] && mv "$item" "$DEST_DIR/" 2>>"$LOG"
done
shopt -u dotglob nullglob
elif [[ -d "$SOURCE" ]]; then
# Moving a specific subdirectory
mv "$SOURCE" "$DEST_DIR/" 2>>"$LOG"
else
# Moving a single file
mv "$SOURCE" "$DEST_DIR/" 2>>"$LOG"
fi
# ==== Clean up any .incomplete files before beets touches them ====
log "Cleaning up .incomplete files in $DEST_DIR"
find "$DEST_DIR" -name "*.incomplete" -type f -delete 2>>"$LOG" || true
# ==== Pre-tag with loudgain (ReplayGain) + autocue (liq_* cue/overlay tags) ====
# Done here, on the staging copy, so Navidrome/AzuraCast see fully-prepped files
# the moment beets moves them into /music.
PREPPED=$(prep_audio "$DEST_DIR" "$LOG" || echo 0)
log "Pre-tagged $PREPPED file(s) with ReplayGain + autocue"
# ==== Strip MusicBrainz / release-identification tags ====
# Manual imports often come from MB-tagged sources (Apple Music rips, Soulseek,
# etc.); Navidrome's BFR scanner uses those tags to fragment album cards, so
# one tagged track imported into an otherwise-clean album gets split off.
# Mirrors the strip step that spotify-retag.py runs in the playlist flow.
STRIPPED=$(strip_mb_tags "$DEST_DIR" "$LOG")
[[ "$STRIPPED" -gt 0 ]] && log "Stripped MB/release tags from $STRIPPED file(s)"
# ==== Trigger beets import (in-process, same container) ====
log "Running beets import on $DEST_DIR"
# Beets can return non-zero on duplicate skip — not fatal.
BEETS_EXIT=0
beet import -q -s "$DEST_DIR" >> "$LOG" 2>&1 || BEETS_EXIT=$?
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
fi
# ==== Trigger Navidrome scan (through the share-health gate) ====
# navidrome-scan.sh refuses to trigger a scan while the QNAP share is down or
# truncated. With ND_SCANNER_PURGEMISSING=always, an ungated full scan against
# a bad mount makes Navidrome purge the entire library (the 2026-06-01 wipe) —
# this script used to fire a raw startScan curl with no such check.
log "Triggering Navidrome scan via navidrome-scan.sh (share-health gated)"
${PIPELINE_DIR:-/app/pipeline}/lib/navidrome-scan.sh >> "$LOG" 2>&1 || log "WARNING: navidrome-scan.sh exited non-zero"
log "=== Finished manual import ==="
log ""
exit 0
+189
View File
@@ -0,0 +1,189 @@
#!/bin/bash
# run-playlist.sh
#
# Runs sldl (vendored binary, subprocess of this same container — which
# rides gluetun's VPN network namespace) for a Spotify playlist, tags
# downloaded files with the playlist name, imports via beets (also in-
# process in this container), and regenerates a Navidrome-readable M3U
# playlist.
#
# Usage: ./run-playlist.sh [--no-m3u] <playlist_name>
# --no-m3u Download and import but skip M3U generation (e.g. liked songs)
set -euo pipefail
# Commands that are *allowed* to exit non-zero must be wrapped explicitly,
# either by `cmd && X=0 || X=$?` to capture exit code, or `cmd || true` when
# we genuinely don't care. Everything else aborts the run on failure.
NO_M3U=false
if [[ "${1:-}" == "--no-m3u" ]]; then
NO_M3U=true
shift
fi
PLAYLIST_NAME="${1:?Usage: $0 [--no-m3u] <playlist_name>}"
# ==== Paths ====
CONFIG_FILE=${ALEMBIC_CONFIG_DIR:-/config}/pipeline/${PLAYLIST_NAME}.conf
DROPBOX=${MUSIC_DATA_DIR:-/data/music}/sldl-dropbox/${PLAYLIST_NAME}
PLAYLISTS_DIR=${MUSIC_DATA_DIR:-/data/music}/playlists
QUARANTINE_DIR=${MUSIC_DATA_DIR:-/data/music}/Songs/untagged
LOG_DIR=${ALEMBIC_CONFIG_DIR:-/config}/logs
LOG=${LOG_DIR}/${PLAYLIST_NAME}-$(date +%Y%m%d).log
SLDL_BIN=${PIPELINE_DIR:-/app/pipeline}/sldl
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/tag-guard.sh
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
# ==== Setup ====
mkdir -p "$LOG_DIR" "$DROPBOX" "$PLAYLISTS_DIR"
log() {
echo "[$(date -Iseconds)] $*" | tee -a "$LOG"
}
log "=== Starting playlist run: $PLAYLIST_NAME ==="
# ==== Sanity checks ====
if [[ ! -f "$CONFIG_FILE" ]]; then
log "ERROR: Config file not found at $CONFIG_FILE"
exit 1
fi
# ==== Run sldl (vendored binary, subprocess of this container) ====
log "Running sldl for: $PLAYLIST_NAME"
# sldl exit code is informational: per-track Soulseek failures are normal and
# do NOT mean we should abort — we still want to tag/import whatever it did
# download. A non-zero exit here is logged but not fatal.
#
# Hard timeout: without it a stuck sldl hangs FOREVER holding the shared lock,
# which silently skips every later-scheduled playlist for the night. (Seen
# 2026-06-18: a transient Spotify client-creds failure made sldl fall back to
# interactive OAuth — "manually open: https://accounts.spotify.com/..." — and
# it waited 7h on a browser callback that never comes.) timeout TERMs the run
# and KILLs after a grace period; there's no leftover container to clean up
# now that sldl is a plain subprocess instead of a docker-compose service.
SLDL_TIMEOUT="${SLDL_TIMEOUT:-2700}" # 45 min; override via env
SLDL_EXIT=0
timeout --kill-after=30s "$SLDL_TIMEOUT" \
"$SLDL_BIN" -c "$CONFIG_FILE" >> "$LOG" 2>&1 \
|| SLDL_EXIT=$?
if [[ "$SLDL_EXIT" -eq 124 || "$SLDL_EXIT" -eq 137 ]]; then
log "ERROR: sldl exceeded ${SLDL_TIMEOUT}s and was killed (likely a hang)"
fi
log "sldl finished with exit code $SLDL_EXIT"
# ==== Tag every file in this playlist's dropbox with GROUPING=<playlist> ====
# We tag all files in the dropbox (not just files new to this run) for
# two reasons:
# 1. If a previous sldl run crashed mid-flight, the files it managed to
# download are still sitting here and would otherwise be skipped.
# 2. Re-tagging an already-correct file is a no-op (metaflac/id3v2 just
# overwrite with the same value).
log "Tagging files in $DROPBOX with GROUPING=$PLAYLIST_NAME"
NEW_FLAC_COUNT=0
NEW_MP3_COUNT=0
while IFS= read -r -d '' file; do
metaflac --remove-tag=GROUPING --set-tag="GROUPING=${PLAYLIST_NAME}" "$file" 2>>"$LOG" || true
NEW_FLAC_COUNT=$((NEW_FLAC_COUNT + 1))
done < <(find "$DROPBOX" -name "*.flac" -type f -print0)
while IFS= read -r -d '' file; do
id3v2 --TIT1 "${PLAYLIST_NAME}" "$file" 2>>"$LOG" || true
NEW_MP3_COUNT=$((NEW_MP3_COUNT + 1))
done < <(find "$DROPBOX" -name "*.mp3" -type f -print0)
log "Tagged $NEW_FLAC_COUNT FLAC and $NEW_MP3_COUNT MP3 files"
# ==== Clean up any .incomplete files before beets touches them ====
log "Cleaning up .incomplete files in dropbox"
find "$DROPBOX" -name "*.incomplete" -type f -delete 2>>"$LOG" || true
# ==== Rewrite tags from Spotify (source of truth for matched tracks) ====
# Reads Spotify creds + playlist URL from the same conf sldl used. For each file
# in the dropbox that matches a Spotify playlist track, overwrites ARTIST
# (semicolon-joined), ALBUMARTIST (primary), ALBUM, TITLE, TRACKNUMBER,
# DISCNUMBER, DATE. GROUPING is preserved. Files that don't match are left for
# the fallback step below.
SPOTIFY_URL=$(sed -n 's/^input *= *//p' "$CONFIG_FILE" | tr -d ' ')
SPOTIFY_CLIENT_ID=$(sed -n 's/^spotify-id *= *//p' "$CONFIG_FILE" | tr -d ' ')
SPOTIFY_CLIENT_SECRET=$(sed -n 's/^spotify-secret *= *//p' "$CONFIG_FILE" | tr -d ' ')
if [[ -n "$SPOTIFY_URL" && -n "$SPOTIFY_CLIENT_ID" && -n "$SPOTIFY_CLIENT_SECRET" ]]; then
log "Rewriting tags from Spotify for files in $DROPBOX"
if SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIENT_SECRET" \
python3 ${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py "$SPOTIFY_URL" "$DROPBOX" >> "$LOG" 2>&1; then
log "Spotify retag complete"
else
log "WARNING: Spotify retag failed (exit $?) — continuing with sldl-supplied tags"
fi
else
log "Skipping Spotify retag — missing input/spotify-id/spotify-secret in $CONFIG_FILE"
fi
# ==== Quarantine any files still missing essential tags ====
QUARANTINED=$(quarantine_untagged "$DROPBOX" "$QUARANTINE_DIR" "$LOG" || echo 0)
if [[ "$QUARANTINED" -gt 0 ]]; then
log "Quarantined $QUARANTINED file(s) to $QUARANTINE_DIR — fix tags and re-import via import-track.sh"
fi
# ==== Set ALBUMARTIST fallback so Navidrome doesn't coin ghost combined artists ====
AA_SET=$(set_albumartist_fallback "$DROPBOX" "$LOG" || echo 0)
[[ "$AA_SET" -gt 0 ]] && log "Set ALBUMARTIST fallback on $AA_SET file(s) (primary artist before first separator)"
# ==== Pre-tag with loudgain (ReplayGain) + autocue (liq_* cue/overlay tags) ====
# Done here, on the dropbox copy, so Navidrome/AzuraCast see fully-prepped files
# the moment beets moves them into /music.
PREPPED=$(prep_audio "$DROPBOX" "$LOG" || echo 0)
log "Pre-tagged $PREPPED file(s) with ReplayGain + autocue"
# ==== Trigger beets import (in-process, same container) ====
log "Running beets import on $DROPBOX"
# Beets sometimes returns non-zero when it skips duplicates — not fatal.
BEETS_EXIT=0
beet import -q -s "$DROPBOX" >> "$LOG" 2>&1 || BEETS_EXIT=$?
log "beets import finished with exit code $BEETS_EXIT"
# ==== Diagnostic: verify tags exist in beets DB ====
# || true, not || echo 0: under pipefail a failed `beet ls` would otherwise
# append a second "0" after the one wc already printed ("0\n0").
TAGGED_COUNT=$(beet ls "grouping:${PLAYLIST_NAME}" 2>>"$LOG" | wc -l || true)
log "Beets has $TAGGED_COUNT tracks tagged with grouping:$PLAYLIST_NAME"
# ==== Regenerate M3U based on beets library state ====
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
{
echo "#EXTM3U"
echo "$BEET_OUTPUT"
} > "$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
fi
log "=== Finished playlist run: $PLAYLIST_NAME ==="
log ""
exit 0
+72
View File
@@ -0,0 +1,72 @@
# Template for per-playlist sldl configs (sldl 2.6.0.0).
# Flags verified against `sldl --help` output.
#
# sldl runs as a subprocess inside the alembic container (same container as
# beets, on gluetun's network namespace for VPN-routed Soulseek P2P).
# ==== Soulseek credentials ====
# Substituted by the app's credential_service (not regen.sh) whenever the
# soulseek secret is saved/updated — every rendered <playlist>.conf gets
# re-patched at that point, independent of playlist add/remove.
user = SOULSEEK_USER
pass = SOULSEEK_PASS
# ==== Spotify API credentials ====
# Substituted by credential_service the same way, on the spotify secret.
spotify-id = SPOTIFY_CLIENT_ID
spotify-secret = SPOTIFY_CLIENT_SECRET
# ==== Input (Spotify playlist URL) ====
input = SPOTIFY_URL
input-type = spotify
# ==== Output paths ====
# SLDL_DROPBOX_ROOT is substituted by regen.sh at render time from
# $MUSIC_DATA_DIR (sldl's own config format has no env-var expansion).
path = SLDL_DROPBOX_ROOT/PLAYLIST_NAME
playlist-path = SLDL_DROPBOX_ROOT/PLAYLIST_NAME/_sldl.m3u8
write-playlist = true
# ==== Naming ====
name-format = {artist} - {title}
# ==== Concurrent downloads ====
# Higher values download from more peers simultaneously.
# Each peer is usually ~100-500 KB/s, so 10 concurrent ≈ 1-5 MB/s total.
# Bandwidth isn't the bottleneck on Soulseek - peer availability is.
concurrent-downloads = 10
# ==== Search behavior ====
fast-search = true
search-timeout = 60000
max-stale-time = 50000
searches-per-time = 34
searches-renew-time = 220
# ==== Soulseek listen port ====
# Picking a port that:
# - is below Linux's ephemeral range (32768-60999), so the OS won't auto-pick
# it for slskd's outbound peer connections;
# - is NOT one of slskd's bound ports inside gluetun (slskd uses 5030 HTTP,
# 5031 HTTPS, 50300 Soulseek listen);
# - is NOT used by any other gluetun-network container (qbittorrent: 6881,
# shelfmark: 8084).
# 9876 fits all three.
listen-port = 9876
# ==== Quality hard filters (strict - files not matching are rejected) ====
format = flac,mp3
min-bitrate = 320
# ==== Quality preferences (soft - controls ranking among accepted files) ====
pref-format = flac,wav,mp3
pref-min-bitrate = 320
pref-max-bitrate = 2500
# ==== Strict filtering (hard - rejects files that don't match) ====
strict-title = true
strict-artist = true
# ==== Misc ====
verbose = true
no-browse-folder = true
+413
View File
@@ -0,0 +1,413 @@
Artist,Title,Album,Length
,,,508:29
32ki; Hatsune Miku; 重音テト,メズマライザー (feat. 初音ミク&重音テト),メズマライザー,2:37
33 Below; DRIIA,LOYAL,LOYAL,4:15
[KRTM]; TWAN,Black Widow,Gum EP,6:21
\berhaupt & Au_erdem,Late Breakfast (Original),"Badarchin, Vol. 04",7:08
a r u k a.; DOVVE; echoed.; flawguri; Stardew,Aquarius,STORYBOOK: Chapter 2,5:09
A*S*Y*S,Bassface,Bassface EP,6:30
Above & Beyond/Oceanlab,Satellite,Super8 & Tab Presents: 20 Years Of Anjunabeats,3:29
Above & Beyond; Marty Longstaff,Flying By Candlelight - Above & Beyond Club Mix,Flying By Candlelight,4:02
Above & Beyond; Richard Bedford,Northern Soul,Common Ground,5:35
Adam Beyer; Kölsch,What You Need - Kölsch Remix,What You Need (Kölsch Remix),6:40
Aiko , ETHERIA,TECHNIKA [memory system],TECHNIKA [memory system]
AIROD,Universe of 90's Techno Parties [Molekul 007],Universe of 90's Techno Parties [Molekul 007],7:56
Alaskan Tapes,All We Can't See,All We Can't See / An Image,1:56
Alegant/Tube & Berger,Get Down (Short Edit),False Gods EP (Short Edits),3:34
Alesso; Tove Lo,Heroes (we could be),Forever,3:31
Alex Luciano,Under the Rain,Under the Rain,3:35
Alex Preston,Love You Better - Extended Mix,Love You Better,5:29
Alignment,Interference - Luca Agnelli Re,Interference,7:18
Alignment; Regal,Interference - Regal Remix,Interference,6:24
Alphazone,Alphazone Sunrise - Original C,Alphazone Sunrise,7:08
alt-J; Danny Brown; The Alchemist,Deadcrush (feat. Danny Brown) - Alchemist x Trooko Version,Deadcrush (feat. Danny Brown) [Alchemist x Trooko Version],4:03
altrice; Blue Dust Archive,bda creature,compciter ep,4:53
Amos & Riot Night; Ren Faye,Different View,Different View,4:13
Amos & Riot Night; Ren Faye,Different View,Different View,8:42
Amy Axegale,In Your Section,In Your Section,5:33
Andain; Myon; Shane 54,Promises - Myon & Shane 54 Sum,Promises,3:48
Andrea Kaeri/Phonez/BEKIMACHINE,Nothing for You,Dreamlike,5:26
Anetha; Sugar,Candy from Strangers,Don't Rush to Grow Up,8:07
ANNA; Miss Kittin,Forever Ravers,Speicher 112,6:00
Anton Kling,Mareld,Mareld,7:32
Apashe; VOLAC,Distance - Volac Remix,Distance (Volac Remix),3:35
Armin van Buuren; Tempo Giusto; i_o,Mr. Navigator - i_o Remix,"Balance (Remixes, Pt. 1)",5:54
ARTY; Brina Knauss,Hope - Brina Knauss Remix,Hope (Brina Knauss Remix),5:58
ARXMANE,'98 RACE,'98 RACE,1:40
Asagaoaudio,ring and portrait,the next game EP,5:52
At Dawn,Bare Essentials (Vip Edit),Routine Espresso 002: Cafe Dreams,5:36
Avicii,Silhouettes - Original Mix,Silhouettes,7:00
Avicii; Sandro Cavazza,Without You (feat. Sandro Cavazza),AVĪCI (01),2:58
Baalti,Aame,Baalti,2:28
Baalti,Ustad,Thandai - Flavours of the East,6:11
BABii; Pholo,EMBER (crashed) (crashed)_PHOLO,EMBER (crashed) (crashed)_PHOLO,3:46
Baby Billy Freeman,Bible Bonkers (Theme Song),The Righteous Gemstones: Season 3 (HBO Original Series Soundtrack),0:48
Baile,Dazed,Siren,6:46
Barry Can't Swim; somedeadbeat,Deadbeat Gospel,When Will We Land?,4:14
Basstakil,What If We Are Not Alone,What If We Are Not Alone,6:30
Beatenberg,The Lighthouse of Alexandria,On the way to Beatenberg,2:39
Belljack,불행한,Searching Within,6:03
Benny Bridges,Lucky Penny (Black Loops Remix),Lucky Penny (Black Loops Remix),5:33
biskuwi,Veil,Two Minds,5:23
Bjorn Salvador/Middle Aged Dad,Steina,Steina,8:08
Black Eyed Peas,Pump It (GWE,Pump It (GWE - Single,5:31
Bladee,Be Nice 2 Me (Euphorizer Remix),Be Nice 2 Me (Euphorizer Remix) - Single,3:21
BLCKLGHT,Away From You,Away From You,4:21
Bodysync; Ryan Hemsworth; Giraffage,Rhythm,Rhythm,2:32
Bomat,Doing All Right,Doing All Right,7:17
Boris Brejcha,Lieblingsmensch - Edit,Lieblingsmensch (Edit),3:40
Boris Brejcha,Never Look Back - Edit,Never Look Back (Edit),3:25
Boris Brejcha; Ginger,Happinezz - Edit,Happinezz (Edit),4:05
Bou; Trigga,Veteran,Scorpio,4:29
Brendan Park,Relic 19,Melt EP,4:33
Brooks Aleksander,You,You,4:11
Bruce Hornsby,5.Song C - Instrumental,Spirit Trail,2:47
Bug; Boyskin,Wait,Wait,6:31
Buku,TiiGA,TiiGA,3:48
BUSDRIVER; Aesop Rock; Danny Brown,Ego Death,Perfect Hair,6:00
Catz 'n Dogz; Jono McCleery,Would You Believe,Friendship,4:30
CBDJ,towards you,towards you,6:08
Chambray,Anew,Work That,7:42
Chance the Rapper; Future,Smoke Break (feat. Future),Coloring Book,3:46
CHANEY,Blank Space,Blank Space,5:17
Chicago Loop,Supernova 303,Memories Made / Supernova 303,3:36
Chris Luno,Better Try Again,Better Try Again,5:18
Chris Luno/Beron,The Hike,The Hike,7:46
Chris Luno/Beron,Friday,The Hike,6:43
Chris Luno/Beron,Saturday,The Hike,7:37
CJ Bolland; The Advent; Enrico Sangiuliano,Camargue 2019 - Enrico Sangiuliano Remix,Camargue 2019,8:14
Clams Casino; Vince Staples,All Nite (feat. Vince Staples),32 Levels (Deluxe),2:47
clipping.; Cocc Pistol Cree,Work Work,CLPPNG,3:43
Coastal,New Life,Cohesion,2:21
Cosmo's Midnight; Sarah Bonito,Hurt (feat. Sarah Bonito),Moments - EP,3:30
Crimsen/Feyln/Oliver Wickham,Grey Skies (Gorge Remix),Grey Skies (Gorge Remix),6:47
Cynthoni; Sewerslvt; AgonyOST,"Quitting Is Hard, Not Quitting Is Harder",SMOKE IT TO THE BUTT,5:49
dUO (Sakura Fujiwara),DdK _ ;_Dk (Itsuka Mita Eiga Mitai Ni),AIRPORT,3:19
D-Jastic,Up To No Good - Extended Mix,Up To No Good (Extended Mix),5:11
D-Mad; ARTY,She Gave Happiness - Arty Remix,"Digital Society International, Volume Three - Mixed by Ashley Wallbridge & Activa",6:05
Daijiro Nakagawa,Concentration,Loop Lullaby,2:44
Dan be; Alex Bone; hej motti,Ooh - hej motti Remix,Ooh (hej motti Remix),2:58
Danny Brown; Kendrick Lamar; Ab-Soul; Earl Sweatshirt,Really Doe,Atrocity Exhibition,5:19
Danny Brown; Purity Ring,25 Bucks (feat. Purity Ring),Old,3:30
Daphni; Caribou,Waiting So Long,Waiting So Long,3:54
Darby; OOTORO,PARTY,PARTY,2:08
Darc Marc,Dirty Rocking Bass - Dirty Rocking Bassline,Dirty Rocking Bass (Dirty Rocking Bassline),7:48
dark cat; juu; Cinders,BUBBLE TEA,BUBBLE TEA,4:03
Dash Berlin; Emma Hewitt,Waiting,The New Daylight,9:28
Data_haven,Utopia - Reprise,Data_haven [re-constructed],5:23
Davey Asprey,Catch,Catch,3:20
David Guetta; Sia,Titanium (feat. Sia),Nothing but the Beat 2.0,4:05
David Guetta; USHER,Without You (feat. Usher),Nothing but the Beat 2.0,3:28
Deadmau5,The Veldt V Porter Robinson - Language,The Veldt V Porter Robinson - Language - Single,2:07
deadmau5; i_o,Imaginary Friends - i_o Remix,"We Are Friends, Vol. 8",6:16
deadmau5; Rinzen,Monophobia - Rinzen Remix,mau5ville: Level 1,6:41
Death Grips,Beware,Exmilitary,5:52
Deborah de Luca; Marika Rossa,In Hypnose,In Hypnose,6:35
Diamond Dallas Tex; Lil Texas,Work My Body,The Afters,3:35
Diffrent,A Little Closer (Extended)145,A Little Closer (Extended)145 - Single,5:18
Diveo,Ferris Wheel,Singles Club Executive Lounge: Celebrating 50 Years of Activia Benz,3:05
DJ BORING,Like Water - Edit,Like Water,6:40
DJ Dextro,Sideral,Sideral,6:11
dj ignorant,i don't want to lose you,i don't want to lose you,5:17
DJ Koze,"Pick Up - 12"" Extended Disco Version",Pick Up,10:01
DJ PP,Desert Night (Extended Mix),Desert Night,6:37
DJ Seinfeld; ARY,Of Joy,Everything U,3:19
DJ_Dave; kitty ray,Still Miss U,Still Miss U,3:47
doechii,nissan altima (wheth,nissan altima (wheth - Single,2:10
Double Touch/Reigan,Greatest Day,Greatest Day,3:49
DRT; Alex K,Take Me Away - Alex K Remix,Take Me Away,2:27
Drunken Kong,Realize,Repeat,7:27
dublon; Deza,Sweet Nothings,Sweet Nothings,2:22
Duke Hugh,Greenleaf,Canvas,4:29
DV-i,Genconfinal,Metronomicon Audio 6.0,1:00
Eclipse,24-7 - Original Mix,24-7,8:04
Ed Balloon; Open Mike Eagle; They Hate Change,Missed Call,Missed Call,3:55
Effy; Mall Grab,iluv,iluv,4:17
Ejeca,U Gave Me Love,U Gave Me Love,2:21
Eleni Foureira; ARCADE,Disco-Tech,Disco-Tech,3:19
EMBRZ,Don't Look Back,Don't Look Back,3:27
EMBRZ; Montgomery,Count To Three,Count To Three,3:18
Eric Prydz,Pjanoo - Club Mix,Pjanoo,7:30
Ettu Brute,Know,Concrete,3:15
Ezekiel,help_urself (breakcore),help_urself (Alternates),1:58
F-Rontal,Inner Souls,Inner Souls,6:58
FallenLights.,You Are Enough,For Her,1:51
Farkey/Hayedeh,Flashback,Flashback,3:13
Felix Cartal; Hanne Mjøen,My Last Song (Felix Cartal's After Hours Mix),My Last Song (Felix Cartal's After Hours Mix),3:58
FenixForce,Domestic Violence,Inexcusable,5:52
Fhase 87,Redemption,Redemption EP,5:24
Flying Lotus; Kendrick Lamar,Never Catch Me,You're Dead!,3:54
Flywright,Thinkin' Bout You Lately,Hush Your Fuss,2:42
Frankyeffe; Teenage Mutants,Polymath,Polymath,6:11
Fred again.. & Anderson .Paak & Chika,places to be (CLIPZ remix),places to be (remixes),3:04
Fred again..; Anderson .Paak; CHIKA; CLIPZ,places to be - CLIPZ remix,places to be (remixes),3:04
Fred again..; Baby Keem; Nia Archives,leavemealone - Nia Archives Remix,USB,2:59
FROOTS,Papaya,Papaya,3:12
Frost Children,Satellites,Tweaker Poem,3:31
Gabriel & Dresden; Sub Teal; C,Only Road - Cosmic Gate Remix,Only Road (Cosmic Gate Remix),3:45
Gensuke Kanki,Vertige,Durée,3:54
Gilles Bernies,Knowing,Knowing,6:01
Gilmer Galibard,Bougarabou,A Baker's Shit Ton,5:36
Glen Campbell,Wichita Lineman - Remastered 2001,Wichita Lineman (Remastered),3:06
Goncalo M,Infinite Black,Best Weapons,6:08
Good Times Ahead; Vince Staples,Little Bit of This (feat. Vince Staples),Good Times Ahead,2:40
Greg Gonzalez,Country of the Mind (Edit),Country Of The Mind,6:25
Hadone,How To Fake Success - Original mix,Defining Persuasion (Original),6:27
Hailaker; S. Carey,Wavepool,Wavepool,3:38
harmony haven; Phusis,Violet Flash,Healing Visions,2:48
Helios,Bless This Morning Year,Eingya (Remastered),6:01
HNNY,Noth...,Nothing,5:12
Hope Tala,Tiptoeing - Tommy Villiers Remix,Tiptoeing (Tommy Villiers Remix),3:08
Hypsidia/Altriparty,Viral (Notre Dame Remix),Viral (The Remixes),4:14
HYUKOH; 落日飛車 Sunset Rollercoaster,Aaaannnnteeeeennnaaaaaa,AAA,6:50
"i_o, Raito",Rivolution,rivolution,5:12
i_o; Lights,Annihilation,Annihilation,4:07
i_o; Raito,Come with Me,House of God,6:05
i_o; Raito,Don't Stop,révolution,5:24
Ian van Dahl; Marsha,Castles In The Sky (featuring,Castles In The Sky,6:46
Ilja Alexander,Someday (LCAW Remix),Someday (LCAW Remix),6:38
Illiya Korniyenko,Ithori - Original,The Collapse Of Future Vol.3,6:34
Inafekt; Big Jon,Blaze (Extended Mix),Blaze,5:35
Infeed; INNERGATED,Freedom Inside Your Illness - LS41 Remix,Resurrected in the Hell,5:00
Isambard Khroustaliov,Atoll Song,"This Is My Private Beach, This Is My Jetsam",6:43
ISOxo; Ninajirachi,SHYPOP,kidsgonemad!,3:28
J.Shore; Mango; Orion; Shingo Nakamura,Raining In Osaka - Shingo Nakamura Remix,Raining In Osaka,7:44
Jacana People,Imari,Imari,4:23
jahjaylee,"Right, Right. Right?",jah,6:43
Jake Kaiser,Kestrel,Winter Selections 06,3:21
jamesjamesjames,"Its Not You, Its Me - Club Mix","It's Not You, It's Me (Club Mix)",3:44
jamesjamesjames; snctm.,homesoon.,homesoon.,3:55
jamesjamesjames; Spring Breaker,ibiza is waiting for me,ibiza is waiting for me,5:27
Japano File,Holy.Place,Holy.Place,3:50
Jaques Raupé; Felix Harrer,3 Haselnüsse,3 Haselnüsse,2:28
Jason Ross; Fiora,Through It All,Through It All,3:12
Jauz; i_o,Truth,Truth,5:45
Jayda G,Both Of Us - Edit,Both Of Us,3:49
Jazzanova/Edward Vanzet,I'm Still Here (Winnie & Somow Remix),I'm Still Here - Best's Friends Remixes,6:03
Jend,Forever,Forever,2:38
Jensen Interceptor; Assembler Code,Runner,6th Element,5:17
Jeshi; Ross from Friends,3210 - Ross from Friends Remix,3210 (Ross from Friends Remix),5:52
jizue,photograph feat.中嶋イッキュウ,shiori,5:56
Johnny Jungle,Johnny '94 - Dillinja Remix,Johnny '94 (Dillinja And Noise Of Art & Double D Remixes),6:28
Joy Anonymous,JOY (Up The Street),Cult Classics,3:58
JUKO; robo; wishlane; Mistrrr; The Litehouse; Foli,The Guardian,STORYBOOK,4:56
Julius Abel,Lost,Lost,3:39
Kacy Hill,I Believe In You (feat. Francis and the Lights),Is It Selfish If We Talk About Me Again,0:29
KAISUI,Cyclone,Cyclone EP,4:13
Kay Cee,Escape - Club Mix,Escape,6:51
Kayestone,Atmosphere - Angelic Remix,Atmosphere,8:27
Keiju(JP),Only U Know,Only U Know,6:18
Kesha,Die Young (Lil Texas Edit),Die Young (Lil Texas Edit) - Single,2:15
Kevin Flum,Mansion Tears,Mansion Tears,2:20
kita kouhei,Botanical Specimens,Neospecies,2:58
Kito; Chrome Sparks; IS U IS U,Melter,Melter,4:14
Kloyd,Sur,Night Tales,5:34
KOAN Sound,Shimmer,Moments of Opening,4:00
KOAN Sound,Colour Dance,Moments of Opening,4:29
KOAN Sound,Allure,Moments of Opening,2:51
KOAN Sound,Phantom Sight,Moments of Opening,4:48
Kobe JT,Lately,Lately,3:52
Koriass,Zombies,Love suprême,3:38
Kumi Takahara; aus,Tide,Tide,6:02
Kwengface; Joy Orbison; Overmono,Freedom 2,Freedom 2,3:26
Kyau & Albert; Jeza,Bring You Back - Original Mix,Matching Stories,4:35
Kyuuwaii,sora,bedroom,2:46
Lana Del Rey; Cedric Gervais,Summertime Sadness (Lana Del Rey Vs. Cedric Gervais) - Cedric Gervais Remix,Summertime Sadness (Lana Del Rey Vs. Cedric Gervais) [Cedric Gervais Remix],3:34
Landhouse/Sima Aava,Tales from the Swallow (Pandhora Remix),Tales from the Swallow (Remixes),4:10
Lauren Mia,Colours,Colours,7:26
Le Youth; Lane 8; Jyll,I Will Leave a Light On,About Us,4:00
Les Petits Pilous,Russian Forest,Bielle,3:44
Lil Texas; Irradiate,Highs In My Dreams,Highs In My Dreams,2:26
Lilly Palmer,Shift,All Things Fall,6:52
"Local Nomad, Nihil Young & Paige/Local Nomad/Nihil Young/Paige",Gates (Paige & Nihil Young Remix) - Extended,Gates (Paige & Nihil Young Remix),5:33
Logic1000; yunè pinku,What You Like,What You Like (feat. yunè pinku),3:13
Longo; Marco Lollis,Love Again,Love Again,2:59
longstoryshort,HER,HER,2:36
Loods,Need U,Need U,3:22
Loods; KLP,Freedom,Freedom,2:54
Lord RAJA; Jeremiah Jae,Van Go (feat. Jeremiah Jae),A Constant Moth,2:04
Lorenzo Raganzini; Paolo Ferrara; OGUZ,Raving in Paris - Oguz Journey Mix,Raving in Paris (Oguz Journey Mix),11:18
Luciid,Mob,Mob / D.O.L.L.A,8:12
Luciid,D.O.L.L.A,Mob / D.O.L.L.A,6:38
Luisa Marion,The Kite (Campfire Session) [Live],The Kite (Campfire Session) [Live],3:55
LUV HRTS,CAN WE DO BETTER?,CAN WE DO BETTER?,3:07
LUV HRTS,GONNA GET THRU,GONNA GET THRU,3:20
"LUV HRTS, Mild Minds",U REALLY HURT ME,U REALLY HURT ME,2:42
LYNY; FLY,Pulse,Pulse,2:27
M Field,Andrew,Andrew,2:44
Madeon,GoodFaith Radio (Ep. 1),GoodFaith Radio,59:29
Mall Grab; Skin On Skin,Strangers,Strangers,6:09
"Mango,Orion, J.Shore",When All the Ships Are Gone (Album Mix],Citylanes Airplanes,9:30
Mannequin; prod. DTM,Lay It On Me,Lay It On Me,2:17
Manuel Di Martino,Impact,Alpha Sides Vol. IV,5:10
Mark Michael,Solar Storm - Original Mix,Solar Storm,4:48
Martin Solveig; Faouzia; TRYM,Now Or Never - TRYM Remix,Now Or Never (Remixes),5:17
Masakatsu Takagi,Marginalia #131,Marginalia #131,7:10
Masakatsu Takagi,Marginalia #139,Marginalia #139,2:22
Masakatsu Takagi,Marginalia #143,Marginalia #143,2:53
Masakatsu Takagi,Marginalia #153,Marginalia #153,2:53
Masakatsu Takagi; 坂本美雨,Field Fjord,sorato,1:00
Matteo Vitanza,Acid? Why Not?,We Overcome,6:57
Mees Javois,DSTOP,DSTOP,5:27
Melé,And 1 (Extended Mix),And 1 (Extended Mix) - Single,4:30
Michael Klein,Yuzu,Yuzu,6:47
Middle School,remember,remember,1:23
midnight; ylxr,Promethium Reprise,Lo-Fi_house.Zip,4:00
Mike Posner,I Took a Pill in Ibiza (SeeB r,"At Night, Alone.",3:19
Miksu / Macloud/Makko,Nachts wach (Lila Wolken Bootleg),BRAVO Hits 2023,2:38
Milo; Hemlock Ernst,Souvenir,So The Flies Don't Come,3:45
Murder He Wrote; Idris Miles,Rave Out,Rave Out,3:16
My Sister's Fugazi Shirt,ONE MORE FINAL: I need you,"Man Fears the Darkness, and So He Scrapes Away at the Edges of It With Fire",4:29
Namatjira,Sounds Of Sunday Morning (RIGOONI Remix),Moons Of Yesterday (Remix Project 1),7:53
Nhato; Kenji Sekiguchi,Tokyo Blue Pipe - Kenji Sekigu,Tokyo Blue Pipe (Kenji Sekiguc,6:26
Ninajirachi; daine,It's You,I Love My Computer,2:49
Ninajirachi; daine; underscores,"It's You - underscores' ""Its U"" Remix","It's You (underscores' ""Its U"" Remix)",3:31
Ninajirachi; Effy,CSIRAC - Effy Remix,CSIRAC (Effy Remix),4:33
Ninajirachi; horsegiirL,Delete - horsegiirL Remix,Delete (horsegiirL Remix),2:54
No Buses,Eyes,Eyes,3:20
"NOTION, Chrystal",The Days (NOTION Remix) djsoundtop.com,djsoundtop.com,3:53
Odagled,Time Travel (Monikaze Remix),Time Travel,5:23
Onaisit,"the bit you were here was fun, maybe that makes it all worth it","the bit you were here was fun, maybe that makes it all worth it",2:28
Oncill; lillybug; evangeline; nshvll; Ridoy; clothespin.,Journey Across The Mountain,STORYBOOK: Chapter 2,7:10
Open Mike Eagle; Gold Panda,Ziggy Starfish (Anxiety Raps) [feat. Gold Panda],A Special Episode - EP,2:55
orbe; haruka nakamura; 田辺玄,ship,orbe ,3:50
Otira,Pill,Pill,2:09
Otira,Take Me,Take Me,3:50
Pablo Caballero; Tankhamun,Journey,Journey EP,6:12
Pandhora/Sanguinello,Memories (Rasi Z Remix),Imaginary Crossroads,6:58
Pansy Alert,Larry Fisherman,Wither EP,5:51
Paper Planes,Forever,Forever,2:02
Parvenu,Perfect Now,Perfect Now,3:26
Pasocom Music Club,hikari,Night Flow,4:07
Patrick Holland,Closer,Beaubien Dream,6:57
Pete Heller's Big Love; Eat Me,Big Love - Eat Me Edit,Big Love,4:01
Peter Palace,Lovahman,Lost And Found,5:47
Phil Weeks,The Grind,"Essential Beats, Vol. 5",7:14
PinkPantheress; Anz,All My Friends Know - Anz Remix,to hell with it (Remixes),5:11
piri; Tommy Villiers,soft spot,soft spot,3:39
Pleasurekraft; YellowHeads,Prison Planet,Pleasurekraft presents: Binaries,7:34
Polymod; Sasha,Full Circle,Full Circle,6:00
Porter Robinson, Everything Goes On (Assertive Hardcore Bootleg), Everything Goes On (Assertive Hardcore Bootleg) - Single,4:28
Porter Robinson,Language (Alex H Remix),Language (Alex H Remix) - Single,8:30
Porter Robinson,Look at the Sky (AZURA Remix),Look at the Sky (AZURA Remix),4:01
Porter Robinson,Musician (Synthion Hardcore Edit),Musician (Synthion Hardcore Edit),3:31
Porter Robinson,Something Comforting (Live),Secret Sky 2021 (Live),6:32
Porter Robinson,Sea of Voices (Remix) [Live],Secret Sky 2021 (Live),3:52
Porter Robinson,Goodbye To a World (Remix) [Live],Secret Sky 2021 (Live),3:01
Porter Robinson & Madeon,evil shelter,madeon edit,2:18
Porter Robinson & Madeon,Shelter (Live),Secret Sky 2021 (Live),0:05
Porter Robinson & Mat Zo,Easy (Pixel Terror DNB Flip),Pixel Terror Treats [Volume Four],2:40
Porter Robinson & Totally Enormous Extinct Dinosaurs,Unfold (Live),Secret Sky 2021 (Live),0:06
Porter Robinson; Madeon,Intro (Shelter Edit),Shelter Live,2:11
Porter Robinson; Madeon,Shelter (Shelter Edit),Shelter Live,3:37
Porter Robinson; Madeon,Pay No Mind x Easy (Shelter Edit),Shelter Live,3:57
Porter Robinson; Madeon,Sad Machine (Shelter Edit),Shelter Live,4:25
Porter Robinson; Madeon,You're On (Shelter Edit),Shelter Live,3:15
Porter Robinson; Madeon,OK x Lionhearted (Shelter Edit),Shelter Live,4:19
Porter Robinson; Madeon,Flicker (Shelter Edit),Shelter Live,2:57
Porter Robinson; Madeon,Finale x Cut The Kid (Shelter Edit),Shelter Live,4:14
Porter Robinson; Madeon,Imperium (Shelter Edit),Shelter Live,4:10
Porter Robinson; Madeon,Pop Culture (Shelter Edit),Shelter Live,1:20
Porter Robinson; Madeon,Divinity x Innocence (Shelter Edit),Shelter Live,6:20
Porter Robinson; Madeon,La Lune x Sea Of Voices (Shelter Edit),Shelter Live,2:22
Porter Robinson; Madeon,Fresh Static Snow (Shelter Edit),Shelter Live,2:22
Porter Robinson; Madeon,Home (Shelter Edit),Shelter Live,3:47
Porter Robinson; Madeon,Pixel Empire (Shelter Edit),Shelter Live,2:43
Porter Robinson; Madeon,Icarus x Fellow Feeling (Shelter Edit),Shelter Live,3:42
Porter Robinson; Madeon,Goodbye to a World (Shelter Edit),Shelter Live,5:01
Porter Robinson; Madeon,Encore (Shelter Edit),Shelter Live,5:51
projektbau,Easy 2 Love,Easy 2 Love,1:59
Qplex/Maurice Kaar,Mountains (Yannek Maunz Remix),Mountains (Yannek Maunz Remix),6:15
Raito,Deep Down Inside,Deep Down Inside - EP,5:49
RAY VOLPE,LASERBEAM (ANDEREX,LASERBEAM (ANDEREX - Single,3:12
Remco Beekwilder,LSD - Original Mix,LSD EP,5:21
Roald Velden & EcueD,Moments,Gallery:01 (Compiled By Lessov & Roald Velden),9:15
Romina; Franck Dona; Tone Depth,No Education - Tone Depth Remix,No Education (Tone Depth Remix),3:51
Rosbeh/Paulii,Oceans,Oceans,4:33
Ross from Friends,Talk to Me You'll Understand,You'll Understand,6:57
Rudosa,Smash The System,No Man's Land EP,7:01
Run The Jewels; Zack De La Rocha,Close Your Eyes (And Count to Fuck) [feat. Zack De La Rocha],Run the Jewels 2,3:54
rxflxct,Dreaming,Dreaming,4:03
salute; なかむらみなみ,go!,TRUE MAGIC,2:11
salute; なかむらみなみ; George Daniel,go! - George Daniel Remix,go! (George Daniel Remixes),3:41
Salyu; haruka nakamura,星のクズ α,星のクズ α,2:56
Sam Silver; ALRT; Yona; Leap96,Downtime,Downtime,2:36
Sans Soucis/Jacana People,Air (feat. Jacana People) (Jacana People Remix),Air (Remixes),3:25
Sasson (FR)/idd aziz/FNX OMAR/Taamy,Zani (FNX Omar Remix),Zani,6:25
Sebastian Davidson/Cir:cle,River Battles,River Battles,4:40
Seigen Tokuzawa; Masaki Hayashi,Iambic 9 Poetry,Drift,3:07
Shadow Child; Huxley,Err,Err,5:26
Shaolin Cowboy,Last Train to Peckham,2Be with You,6:00
Shaolin Cowboy,All Night All Summer,All Night All Summer,4:46
SHEE,Close My Eyes (Extended Mix),Close My Eyes,6:06
SHEE,Am I Just Living,Miami Ug 2019,6:01
Shermanology,Boyz N Da Club,Boyz N Da Club,6:17
Shire T,London. Paris. Berlin.,Tomorrows People,4:05
"skrillex, porter robinson",still here (nuphory x luna lenta lightmode mix),still here (nuphory x luna lenta lightmode mix) - Single,3:56
SLIM HUSTLA,2100,Compilation 2,5:48
Some Me,Engage,Engage,6:52
Staggr [257757361],hit me + ! [1419656392],cloudwire [2082910431],1:24
Static,feel,feel,2:54
Stereos,Summer Girl,Stereos (Deluxe Version),2:45
Steve Reich; Kristjan Järvi; Waltraut Wächter; Andreas Hartmann; MDR Leipzig Radio Symphony Orchestra,Duet for two Solo Violins and String Orchestra (Dedicated to and written for Yehudi Menuhin),Steve Reich - Duet,5:34
Sultan + Shepard; Elderbrook,I'll Be Here,"Endless, Dawn",4:52
sumthin sumthin,BEEN THRU,Growing Pains,3:26
sunflwr,you got your own thing going,you got your own thing going,4:03
Sunny Lax; Aneym,Everything's A Lie,Everything's A Lie,3:40
Suulo vs. Curl,Navida (Original Mix),"Kittball Konspiracy, Vol. 19",7:16
Svetec,Ruka Hore,Ruka Hore,5:16
Swimming Paul; Beaux Neptune,Good Girl,Good Girl,3:14
t0ni; Broosnica,keepsake Flip,keepsake Flip,3:04
T78,Ikarus,Ikarus,5:05
T78; Raito,Acid People,Acid People / Make Me Feel,6:59
tai hirose,sink,aquatic recitation,3:05
tai hirose,rain's footsteps,deepwater / gauze,2:05
tai hirose,magnify,magnify,1:02
Tame Impala,Borderline - Blood Orange Remix,Borderline,0:12
Taylor Swift,Love Story (Jake Silva Remix),Love Story (Jake Silva Remix) - Single,4:34
TDJ,Trpaslik Island,SPF INFINI 2,5:11
TDJ; fknsyd,Euphoria,Euphoria,4:14
TDJ; Peterparker69,ただdance in the dark,ただdance in the dark,4:12
Ted Poley; Tony Harnell,Escape From The City ...for City Escape,SONIC ADVENTURE 2 Original Soundtrack (20th Anniversary Edition),2:19
Tentendo; Jordan Dennis,Function,Function,3:18
Tenth Planet; Vincent de Moor,Ghosts - Vincent De Moor Mix,Ghosts,6:46
Terror Jr; AOBeats,Be Some Body,Be Some Body,3:01
The Knocks; TEED,Walking On Water (feat. TEED),HISTORY,3:36
thomfjord,Intrastellar,Intrastellar EP,5:21
thomfjord,Terminal Velocity,Intrastellar EP,5:56
thomfjord,Day , Nite,Intrastellar EP
Tom Jarmey; Ed Hodge,Deeper Pacific,Amber Glass,6:42
Tommie Sunshine; MureKian; Sabrina Signs; Raito,Save the Rave - Raito Remix,Save the Rave,4:30
Tommy Holohan; Clouds,South Beach Burnin Bins (Clouds Remix),RVE002,5:32
Too $hort,You Came to Party,You Came to Party,3:53
Tribz/Calixte,Sweet Disposition (Vijay & Sofia Edit),Sweet Disposition (Vijay & Sofia Edit),3:28
Tritonal; Lourdiz; Kyau & Albe,Love U Right - Kyau & Albert R,"Love U Right (Remixes, Pt. 1)",2:49
Truncate,Our Bodies,TRUNCEI8HT EP,5:12
TWONSKi,DiAMONDS,DiAMONDS,3:08
UMEK,Double the Lust - Original Mix,Double the Lust,7:40
V. Christie,Talk To Me,Talk To Me,3:53
Vanessa Sukowski; ABYSSVM,Lucanus Cervus - Abyssvm Remix,Lucanus Cervus,7:08
Varg²™; Bladee; Skrillex,Is there a place in heaven for boys like me?,"Nordic Flora Series, Pt. 6: Outlaw Music",3:41
Varra,Catto Observatory - Remix,Catto Observatory (Remix),2:47
Veerus,Heavy,FOUR ON JAM 01,6:52
Village,C-R-E-W,C-R-E-W,3:25
Vincent,Love Me Now (Extended),Love Me Now (Extended) - Single,4:07
Vincent de Moor,Fly Away - Extended Vocal Mix,Fly Away,8:21
VTSS,Sober Raving,HELLCAT VOL.1,5:38
Wassu,Escape,Tales of Romance I,7:12
water feature,Pond Bather,Various Birds,1:11
wes mills; meli,this time,this time,3:12
Willaris. K; jamesjamesjames,Silversun,Silversun,5:37
Willo,Past Time,Past Time,5:40
Worakls,Detached Motion (Patrice Bdumel Remix),Hortari & Detached Motion Remixes,6:33
Worakls/Ben Bvhmer/eivxr,Red Dressed (Ben Bvhmer Remix - Short Version),Red Dressed (Ben Bvhmer Remix),3:46
X-COAST,Party Time - Extended,Pianissimo,5:46
X-COAST,The Ultimate - Extended,Pianissimo,6:09
Yeek,Long Time No See,Long Time No See,1:51
yuragi,soon,nightlife,2:46
yuragi,night is young,nightlife,5:44
Zigan Aldi,Heppnar (Iorie Remix),Heppnar (Iorie Remix),7:22
Zmi,Shigatsu ame,Fu-ne,2:50
zodivk,yours (+),yours (+),2:55
adri; dossyx; Feathervane; Stardreams; wintercolor,Atonement,STORYBOOK: Chapter 2,6:12
⣎⡇ꉺლ༽இ•̛)ྀ◞ ༎ຶ ༽ৣৢ؞ৢ؞ؖ ꉺლ,ƪ. ◖ƪ❍⊁◞.,◗щ (*ㅇ△₊⁎❝᷀ົཽ*ೃ:(꒡͡ ❝᷀ົཽ ꉺ¨.·*:・✧⃛(ཽ๑,)✧⃛*
1 Artist Title Album Length
2 508:29
3 32ki; Hatsune Miku; 重音テト メズマライザー (feat. 初音ミク&重音テト) メズマライザー 2:37
4 33 Below; DRIIA LOYAL LOYAL 4:15
5 [KRTM]; TWAN Black Widow Gum EP 6:21
6 \berhaupt & Au_erdem Late Breakfast (Original) Badarchin, Vol. 04 7:08
7 a r u k a.; DOVVE; echoed.; flawguri; Stardew Aquarius STORYBOOK: Chapter 2 5:09
8 A*S*Y*S Bassface Bassface EP 6:30
9 Above & Beyond/Oceanlab Satellite Super8 & Tab Presents: 20 Years Of Anjunabeats 3:29
10 Above & Beyond; Marty Longstaff Flying By Candlelight - Above & Beyond Club Mix Flying By Candlelight 4:02
11 Above & Beyond; Richard Bedford Northern Soul Common Ground 5:35
12 Adam Beyer; Kölsch What You Need - Kölsch Remix What You Need (Kölsch Remix) 6:40
13 Aiko ETHERIA TECHNIKA [memory system] TECHNIKA [memory system]
14 AIROD Universe of 90's Techno Parties [Molekul 007] Universe of 90's Techno Parties [Molekul 007] 7:56
15 Alaskan Tapes All We Can't See All We Can't See / An Image 1:56
16 Alegant/Tube & Berger Get Down (Short Edit) False Gods EP (Short Edits) 3:34
17 Alesso; Tove Lo Heroes (we could be) Forever 3:31
18 Alex Luciano Under the Rain Under the Rain 3:35
19 Alex Preston Love You Better - Extended Mix Love You Better 5:29
20 Alignment Interference - Luca Agnelli Re Interference 7:18
21 Alignment; Regal Interference - Regal Remix Interference 6:24
22 Alphazone Alphazone Sunrise - Original C Alphazone Sunrise 7:08
23 alt-J; Danny Brown; The Alchemist Deadcrush (feat. Danny Brown) - Alchemist x Trooko Version Deadcrush (feat. Danny Brown) [Alchemist x Trooko Version] 4:03
24 altrice; Blue Dust Archive bda creature compciter ep 4:53
25 Amos & Riot Night; Ren Faye Different View Different View 4:13
26 Amos & Riot Night; Ren Faye Different View Different View 8:42
27 Amy Axegale In Your Section In Your Section 5:33
28 Andain; Myon; Shane 54 Promises - Myon & Shane 54 Sum Promises 3:48
29 Andrea Kaeri/Phonez/BEKIMACHINE Nothing for You Dreamlike 5:26
30 Anetha; Sugar Candy from Strangers Don't Rush to Grow Up 8:07
31 ANNA; Miss Kittin Forever Ravers Speicher 112 6:00
32 Anton Kling Mareld Mareld 7:32
33 Apashe; VOLAC Distance - Volac Remix Distance (Volac Remix) 3:35
34 Armin van Buuren; Tempo Giusto; i_o Mr. Navigator - i_o Remix Balance (Remixes, Pt. 1) 5:54
35 ARTY; Brina Knauss Hope - Brina Knauss Remix Hope (Brina Knauss Remix) 5:58
36 ARXMANE '98 RACE '98 RACE 1:40
37 Asagaoaudio ring and portrait the next game EP 5:52
38 At Dawn Bare Essentials (Vip Edit) Routine Espresso 002: Cafe Dreams 5:36
39 Avicii Silhouettes - Original Mix Silhouettes 7:00
40 Avicii; Sandro Cavazza Without You (feat. Sandro Cavazza) AVĪCI (01) 2:58
41 Baalti Aame Baalti 2:28
42 Baalti Ustad Thandai - Flavours of the East 6:11
43 BABii; Pholo EMBER (crashed) (crashed)_PHOLO EMBER (crashed) (crashed)_PHOLO 3:46
44 Baby Billy Freeman Bible Bonkers (Theme Song) The Righteous Gemstones: Season 3 (HBO Original Series Soundtrack) 0:48
45 Baile Dazed Siren 6:46
46 Barry Can't Swim; somedeadbeat Deadbeat Gospel When Will We Land? 4:14
47 Basstakil What If We Are Not Alone What If We Are Not Alone 6:30
48 Beatenberg The Lighthouse of Alexandria On the way to Beatenberg 2:39
49 Belljack 불행한 Searching Within 6:03
50 Benny Bridges Lucky Penny (Black Loops Remix) Lucky Penny (Black Loops Remix) 5:33
51 biskuwi Veil Two Minds 5:23
52 Bjorn Salvador/Middle Aged Dad Steina Steina 8:08
53 Black Eyed Peas Pump It (GWE Pump It (GWE - Single 5:31
54 Bladee Be Nice 2 Me (Euphorizer Remix) Be Nice 2 Me (Euphorizer Remix) - Single 3:21
55 BLCKLGHT Away From You Away From You 4:21
56 Bodysync; Ryan Hemsworth; Giraffage Rhythm Rhythm 2:32
57 Bomat Doing All Right Doing All Right 7:17
58 Boris Brejcha Lieblingsmensch - Edit Lieblingsmensch (Edit) 3:40
59 Boris Brejcha Never Look Back - Edit Never Look Back (Edit) 3:25
60 Boris Brejcha; Ginger Happinezz - Edit Happinezz (Edit) 4:05
61 Bou; Trigga Veteran Scorpio 4:29
62 Brendan Park Relic 19 Melt EP 4:33
63 Brooks Aleksander You You 4:11
64 Bruce Hornsby 5.Song C - Instrumental Spirit Trail 2:47
65 Bug; Boyskin Wait Wait 6:31
66 Buku TiiGA TiiGA 3:48
67 BUSDRIVER; Aesop Rock; Danny Brown Ego Death Perfect Hair 6:00
68 Catz 'n Dogz; Jono McCleery Would You Believe Friendship 4:30
69 CBDJ towards you towards you 6:08
70 Chambray Anew Work That 7:42
71 Chance the Rapper; Future Smoke Break (feat. Future) Coloring Book 3:46
72 CHANEY Blank Space Blank Space 5:17
73 Chicago Loop Supernova 303 Memories Made / Supernova 303 3:36
74 Chris Luno Better Try Again Better Try Again 5:18
75 Chris Luno/Beron The Hike The Hike 7:46
76 Chris Luno/Beron Friday The Hike 6:43
77 Chris Luno/Beron Saturday The Hike 7:37
78 CJ Bolland; The Advent; Enrico Sangiuliano Camargue 2019 - Enrico Sangiuliano Remix Camargue 2019 8:14
79 Clams Casino; Vince Staples All Nite (feat. Vince Staples) 32 Levels (Deluxe) 2:47
80 clipping.; Cocc Pistol Cree Work Work CLPPNG 3:43
81 Coastal New Life Cohesion 2:21
82 Cosmo's Midnight; Sarah Bonito Hurt (feat. Sarah Bonito) Moments - EP 3:30
83 Crimsen/Feyln/Oliver Wickham Grey Skies (Gorge Remix) Grey Skies (Gorge Remix) 6:47
84 Cynthoni; Sewerslvt; AgonyOST Quitting Is Hard, Not Quitting Is Harder SMOKE IT TO THE BUTT 5:49
85 dUO (Sakura Fujiwara) DdK _ ;_Dk (Itsuka Mita Eiga Mitai Ni) AIRPORT 3:19
86 D-Jastic Up To No Good - Extended Mix Up To No Good (Extended Mix) 5:11
87 D-Mad; ARTY She Gave Happiness - Arty Remix Digital Society International, Volume Three - Mixed by Ashley Wallbridge & Activa 6:05
88 Daijiro Nakagawa Concentration Loop Lullaby 2:44
89 Dan be; Alex Bone; hej motti Ooh - hej motti Remix Ooh (hej motti Remix) 2:58
90 Danny Brown; Kendrick Lamar; Ab-Soul; Earl Sweatshirt Really Doe Atrocity Exhibition 5:19
91 Danny Brown; Purity Ring 25 Bucks (feat. Purity Ring) Old 3:30
92 Daphni; Caribou Waiting So Long Waiting So Long 3:54
93 Darby; OOTORO PARTY PARTY 2:08
94 Darc Marc Dirty Rocking Bass - Dirty Rocking Bassline Dirty Rocking Bass (Dirty Rocking Bassline) 7:48
95 dark cat; juu; Cinders BUBBLE TEA BUBBLE TEA 4:03
96 Dash Berlin; Emma Hewitt Waiting The New Daylight 9:28
97 Data_haven Utopia - Reprise Data_haven [re-constructed] 5:23
98 Davey Asprey Catch Catch 3:20
99 David Guetta; Sia Titanium (feat. Sia) Nothing but the Beat 2.0 4:05
100 David Guetta; USHER Without You (feat. Usher) Nothing but the Beat 2.0 3:28
101 Deadmau5 The Veldt V Porter Robinson - Language The Veldt V Porter Robinson - Language - Single 2:07
102 deadmau5; i_o Imaginary Friends - i_o Remix We Are Friends, Vol. 8 6:16
103 deadmau5; Rinzen Monophobia - Rinzen Remix mau5ville: Level 1 6:41
104 Death Grips Beware Exmilitary 5:52
105 Deborah de Luca; Marika Rossa In Hypnose In Hypnose 6:35
106 Diamond Dallas Tex; Lil Texas Work My Body The Afters 3:35
107 Diffrent A Little Closer (Extended)145 A Little Closer (Extended)145 - Single 5:18
108 Diveo Ferris Wheel Singles Club Executive Lounge: Celebrating 50 Years of Activia Benz 3:05
109 DJ BORING Like Water - Edit Like Water 6:40
110 DJ Dextro Sideral Sideral 6:11
111 dj ignorant i don't want to lose you i don't want to lose you 5:17
112 DJ Koze Pick Up - 12" Extended Disco Version Pick Up 10:01
113 DJ PP Desert Night (Extended Mix) Desert Night 6:37
114 DJ Seinfeld; ARY Of Joy Everything U 3:19
115 DJ_Dave; kitty ray Still Miss U Still Miss U 3:47
116 doechii nissan altima (wheth nissan altima (wheth - Single 2:10
117 Double Touch/Reigan Greatest Day Greatest Day 3:49
118 DRT; Alex K Take Me Away - Alex K Remix Take Me Away 2:27
119 Drunken Kong Realize Repeat 7:27
120 dublon; Deza Sweet Nothings Sweet Nothings 2:22
121 Duke Hugh Greenleaf Canvas 4:29
122 DV-i Genconfinal Metronomicon Audio 6.0 1:00
123 Eclipse 24-7 - Original Mix 24-7 8:04
124 Ed Balloon; Open Mike Eagle; They Hate Change Missed Call Missed Call 3:55
125 Effy; Mall Grab iluv iluv 4:17
126 Ejeca U Gave Me Love U Gave Me Love 2:21
127 Eleni Foureira; ARCADE Disco-Tech Disco-Tech 3:19
128 EMBRZ Don't Look Back Don't Look Back 3:27
129 EMBRZ; Montgomery Count To Three Count To Three 3:18
130 Eric Prydz Pjanoo - Club Mix Pjanoo 7:30
131 Ettu Brute Know Concrete 3:15
132 Ezekiel help_urself (breakcore) help_urself (Alternates) 1:58
133 F-Rontal Inner Souls Inner Souls 6:58
134 FallenLights. You Are Enough For Her 1:51
135 Farkey/Hayedeh Flashback Flashback 3:13
136 Felix Cartal; Hanne Mjøen My Last Song (Felix Cartal's After Hours Mix) My Last Song (Felix Cartal's After Hours Mix) 3:58
137 FenixForce Domestic Violence Inexcusable 5:52
138 Fhase 87 Redemption Redemption EP 5:24
139 Flying Lotus; Kendrick Lamar Never Catch Me You're Dead! 3:54
140 Flywright Thinkin' Bout You Lately Hush Your Fuss 2:42
141 Frankyeffe; Teenage Mutants Polymath Polymath 6:11
142 Fred again.. & Anderson .Paak & Chika places to be (CLIPZ remix) places to be (remixes) 3:04
143 Fred again..; Anderson .Paak; CHIKA; CLIPZ places to be - CLIPZ remix places to be (remixes) 3:04
144 Fred again..; Baby Keem; Nia Archives leavemealone - Nia Archives Remix USB 2:59
145 FROOTS Papaya Papaya 3:12
146 Frost Children Satellites Tweaker Poem 3:31
147 Gabriel & Dresden; Sub Teal; C Only Road - Cosmic Gate Remix Only Road (Cosmic Gate Remix) 3:45
148 Gensuke Kanki Vertige Durée 3:54
149 Gilles Bernies Knowing Knowing 6:01
150 Gilmer Galibard Bougarabou A Baker's Shit Ton 5:36
151 Glen Campbell Wichita Lineman - Remastered 2001 Wichita Lineman (Remastered) 3:06
152 Goncalo M Infinite Black Best Weapons 6:08
153 Good Times Ahead; Vince Staples Little Bit of This (feat. Vince Staples) Good Times Ahead 2:40
154 Greg Gonzalez Country of the Mind (Edit) Country Of The Mind 6:25
155 Hadone How To Fake Success - Original mix Defining Persuasion (Original) 6:27
156 Hailaker; S. Carey Wavepool Wavepool 3:38
157 harmony haven; Phusis Violet Flash Healing Visions 2:48
158 Helios Bless This Morning Year Eingya (Remastered) 6:01
159 HNNY Noth... Nothing 5:12
160 Hope Tala Tiptoeing - Tommy Villiers Remix Tiptoeing (Tommy Villiers Remix) 3:08
161 Hypsidia/Altriparty Viral (Notre Dame Remix) Viral (The Remixes) 4:14
162 HYUKOH; 落日飛車 Sunset Rollercoaster Aaaannnnteeeeennnaaaaaa AAA 6:50
163 i_o, Raito Rivolution rivolution 5:12
164 i_o; Lights Annihilation Annihilation 4:07
165 i_o; Raito Come with Me House of God 6:05
166 i_o; Raito Don't Stop révolution 5:24
167 Ian van Dahl; Marsha Castles In The Sky (featuring Castles In The Sky 6:46
168 Ilja Alexander Someday (LCAW Remix) Someday (LCAW Remix) 6:38
169 Illiya Korniyenko Ithori - Original The Collapse Of Future Vol.3 6:34
170 Inafekt; Big Jon Blaze (Extended Mix) Blaze 5:35
171 Infeed; INNERGATED Freedom Inside Your Illness - LS41 Remix Resurrected in the Hell 5:00
172 Isambard Khroustaliov Atoll Song This Is My Private Beach, This Is My Jetsam 6:43
173 ISOxo; Ninajirachi SHYPOP kidsgonemad! 3:28
174 J.Shore; Mango; Orion; Shingo Nakamura Raining In Osaka - Shingo Nakamura Remix Raining In Osaka 7:44
175 Jacana People Imari Imari 4:23
176 jahjaylee Right, Right. Right? jah 6:43
177 Jake Kaiser Kestrel Winter Selections 06 3:21
178 jamesjamesjames It’s Not You, It’s Me - Club Mix It's Not You, It's Me (Club Mix) 3:44
179 jamesjamesjames; snctm. homesoon. homesoon. 3:55
180 jamesjamesjames; Spring Breaker ibiza is waiting for me ibiza is waiting for me 5:27
181 Japano File Holy.Place Holy.Place 3:50
182 Jaques Raupé; Felix Harrer 3 Haselnüsse 3 Haselnüsse 2:28
183 Jason Ross; Fiora Through It All Through It All 3:12
184 Jauz; i_o Truth Truth 5:45
185 Jayda G Both Of Us - Edit Both Of Us 3:49
186 Jazzanova/Edward Vanzet I'm Still Here (Winnie & Somow Remix) I'm Still Here - Best's Friends Remixes 6:03
187 Jend Forever Forever 2:38
188 Jensen Interceptor; Assembler Code Runner 6th Element 5:17
189 Jeshi; Ross from Friends 3210 - Ross from Friends Remix 3210 (Ross from Friends Remix) 5:52
190 jizue photograph feat.中嶋イッキュウ shiori 5:56
191 Johnny Jungle Johnny '94 - Dillinja Remix Johnny '94 (Dillinja And Noise Of Art & Double D Remixes) 6:28
192 Joy Anonymous JOY (Up The Street) Cult Classics 3:58
193 JUKO; robo; wishlane; Mistrrr; The Litehouse; Foli The Guardian STORYBOOK 4:56
194 Julius Abel Lost Lost 3:39
195 Kacy Hill I Believe In You (feat. Francis and the Lights) Is It Selfish If We Talk About Me Again 0:29
196 KAISUI Cyclone Cyclone EP 4:13
197 Kay Cee Escape - Club Mix Escape 6:51
198 Kayestone Atmosphere - Angelic Remix Atmosphere 8:27
199 Keiju(JP) Only U Know Only U Know 6:18
200 Kesha Die Young (Lil Texas Edit) Die Young (Lil Texas Edit) - Single 2:15
201 Kevin Flum Mansion Tears Mansion Tears 2:20
202 kita kouhei Botanical Specimens Neospecies 2:58
203 Kito; Chrome Sparks; IS U IS U Melter Melter 4:14
204 Kloyd Sur Night Tales 5:34
205 KOAN Sound Shimmer Moments of Opening 4:00
206 KOAN Sound Colour Dance Moments of Opening 4:29
207 KOAN Sound Allure Moments of Opening 2:51
208 KOAN Sound Phantom Sight Moments of Opening 4:48
209 Kobe JT Lately Lately 3:52
210 Koriass Zombies Love suprême 3:38
211 Kumi Takahara; aus Tide Tide 6:02
212 Kwengface; Joy Orbison; Overmono Freedom 2 Freedom 2 3:26
213 Kyau & Albert; Jeza Bring You Back - Original Mix Matching Stories 4:35
214 Kyuuwaii sora bedroom 2:46
215 Lana Del Rey; Cedric Gervais Summertime Sadness (Lana Del Rey Vs. Cedric Gervais) - Cedric Gervais Remix Summertime Sadness (Lana Del Rey Vs. Cedric Gervais) [Cedric Gervais Remix] 3:34
216 Landhouse/Sima Aava Tales from the Swallow (Pandhora Remix) Tales from the Swallow (Remixes) 4:10
217 Lauren Mia Colours Colours 7:26
218 Le Youth; Lane 8; Jyll I Will Leave a Light On About Us 4:00
219 Les Petits Pilous Russian Forest Bielle 3:44
220 Lil Texas; Irradiate Highs In My Dreams Highs In My Dreams 2:26
221 Lilly Palmer Shift All Things Fall 6:52
222 Local Nomad, Nihil Young & Paige/Local Nomad/Nihil Young/Paige Gates (Paige & Nihil Young Remix) - Extended Gates (Paige & Nihil Young Remix) 5:33
223 Logic1000; yunè pinku What You Like What You Like (feat. yunè pinku) 3:13
224 Longo; Marco Lollis Love Again Love Again 2:59
225 longstoryshort HER HER 2:36
226 Loods Need U Need U 3:22
227 Loods; KLP Freedom Freedom 2:54
228 Lord RAJA; Jeremiah Jae Van Go (feat. Jeremiah Jae) A Constant Moth 2:04
229 Lorenzo Raganzini; Paolo Ferrara; OGUZ Raving in Paris - Oguz Journey Mix Raving in Paris (Oguz Journey Mix) 11:18
230 Luciid Mob Mob / D.O.L.L.A 8:12
231 Luciid D.O.L.L.A Mob / D.O.L.L.A 6:38
232 Luisa Marion The Kite (Campfire Session) [Live] The Kite (Campfire Session) [Live] 3:55
233 LUV HRTS CAN WE DO BETTER? CAN WE DO BETTER? 3:07
234 LUV HRTS GONNA GET THRU GONNA GET THRU 3:20
235 LUV HRTS, Mild Minds U REALLY HURT ME U REALLY HURT ME 2:42
236 LYNY; FLY Pulse Pulse 2:27
237 M Field Andrew Andrew 2:44
238 Madeon GoodFaith Radio (Ep. 1) GoodFaith Radio 59:29
239 Mall Grab; Skin On Skin Strangers Strangers 6:09
240 Mango,Orion, J.Shore When All the Ships Are Gone (Album Mix] Citylanes Airplanes 9:30
241 Mannequin; prod. DTM Lay It On Me Lay It On Me 2:17
242 Manuel Di Martino Impact Alpha Sides Vol. IV 5:10
243 Mark Michael Solar Storm - Original Mix Solar Storm 4:48
244 Martin Solveig; Faouzia; TRYM Now Or Never - TRYM Remix Now Or Never (Remixes) 5:17
245 Masakatsu Takagi Marginalia #131 Marginalia #131 7:10
246 Masakatsu Takagi Marginalia #139 Marginalia #139 2:22
247 Masakatsu Takagi Marginalia #143 Marginalia #143 2:53
248 Masakatsu Takagi Marginalia #153 Marginalia #153 2:53
249 Masakatsu Takagi; 坂本美雨 Field Fjord sorato 1:00
250 Matteo Vitanza Acid? Why Not? We Overcome 6:57
251 Mees Javois DSTOP DSTOP 5:27
252 Melé And 1 (Extended Mix) And 1 (Extended Mix) - Single 4:30
253 Michael Klein Yuzu Yuzu 6:47
254 Middle School remember remember 1:23
255 midnight; ylxr Promethium Reprise Lo-Fi_house.Zip 4:00
256 Mike Posner I Took a Pill in Ibiza (SeeB r At Night, Alone. 3:19
257 Miksu / Macloud/Makko Nachts wach (Lila Wolken Bootleg) BRAVO Hits 2023 2:38
258 Milo; Hemlock Ernst Souvenir So The Flies Don't Come 3:45
259 Murder He Wrote; Idris Miles Rave Out Rave Out 3:16
260 My Sister's Fugazi Shirt ONE MORE FINAL: I need you Man Fears the Darkness, and So He Scrapes Away at the Edges of It With Fire 4:29
261 Namatjira Sounds Of Sunday Morning (RIGOONI Remix) Moons Of Yesterday (Remix Project 1) 7:53
262 Nhato; Kenji Sekiguchi Tokyo Blue Pipe - Kenji Sekigu Tokyo Blue Pipe (Kenji Sekiguc 6:26
263 Ninajirachi; daine It's You I Love My Computer 2:49
264 Ninajirachi; daine; underscores It's You - underscores' "It’s U" Remix It's You (underscores' "It’s U" Remix) 3:31
265 Ninajirachi; Effy CSIRAC - Effy Remix CSIRAC (Effy Remix) 4:33
266 Ninajirachi; horsegiirL Delete - horsegiirL Remix Delete (horsegiirL Remix) 2:54
267 No Buses Eyes Eyes 3:20
268 NOTION, Chrystal The Days (NOTION Remix) djsoundtop.com djsoundtop.com 3:53
269 Odagled Time Travel (Monikaze Remix) Time Travel 5:23
270 Onaisit the bit you were here was fun, maybe that makes it all worth it the bit you were here was fun, maybe that makes it all worth it 2:28
271 Oncill; lillybug; evangeline; nshvll; Ridoy; clothespin. Journey Across The Mountain STORYBOOK: Chapter 2 7:10
272 Open Mike Eagle; Gold Panda Ziggy Starfish (Anxiety Raps) [feat. Gold Panda] A Special Episode - EP 2:55
273 orbe; haruka nakamura; 田辺玄 ship orbe Ⅰ 3:50
274 Otira Pill Pill 2:09
275 Otira Take Me Take Me 3:50
276 Pablo Caballero; Tankhamun Journey Journey EP 6:12
277 Pandhora/Sanguinello Memories (Rasi Z Remix) Imaginary Crossroads 6:58
278 Pansy Alert Larry Fisherman Wither EP 5:51
279 Paper Planes Forever Forever 2:02
280 Parvenu Perfect Now Perfect Now 3:26
281 Pasocom Music Club hikari Night Flow 4:07
282 Patrick Holland Closer Beaubien Dream 6:57
283 Pete Heller's Big Love; Eat Me Big Love - Eat Me Edit Big Love 4:01
284 Peter Palace Lovahman Lost And Found 5:47
285 Phil Weeks The Grind Essential Beats, Vol. 5 7:14
286 PinkPantheress; Anz All My Friends Know - Anz Remix to hell with it (Remixes) 5:11
287 piri; Tommy Villiers soft spot soft spot 3:39
288 Pleasurekraft; YellowHeads Prison Planet Pleasurekraft presents: Binaries 7:34
289 Polymod; Sasha Full Circle Full Circle 6:00
290 Porter Robinson Everything Goes On (Assertive Hardcore Bootleg) Everything Goes On (Assertive Hardcore Bootleg) - Single 4:28
291 Porter Robinson Language (Alex H Remix) Language (Alex H Remix) - Single 8:30
292 Porter Robinson Look at the Sky (AZURA Remix) Look at the Sky (AZURA Remix) 4:01
293 Porter Robinson Musician (Synthion Hardcore Edit) Musician (Synthion Hardcore Edit) 3:31
294 Porter Robinson Something Comforting (Live) Secret Sky 2021 (Live) 6:32
295 Porter Robinson Sea of Voices (Remix) [Live] Secret Sky 2021 (Live) 3:52
296 Porter Robinson Goodbye To a World (Remix) [Live] Secret Sky 2021 (Live) 3:01
297 Porter Robinson & Madeon evil shelter madeon edit 2:18
298 Porter Robinson & Madeon Shelter (Live) Secret Sky 2021 (Live) 0:05
299 Porter Robinson & Mat Zo Easy (Pixel Terror DNB Flip) Pixel Terror Treats [Volume Four] 2:40
300 Porter Robinson & Totally Enormous Extinct Dinosaurs Unfold (Live) Secret Sky 2021 (Live) 0:06
301 Porter Robinson; Madeon Intro (Shelter Edit) Shelter Live 2:11
302 Porter Robinson; Madeon Shelter (Shelter Edit) Shelter Live 3:37
303 Porter Robinson; Madeon Pay No Mind x Easy (Shelter Edit) Shelter Live 3:57
304 Porter Robinson; Madeon Sad Machine (Shelter Edit) Shelter Live 4:25
305 Porter Robinson; Madeon You're On (Shelter Edit) Shelter Live 3:15
306 Porter Robinson; Madeon OK x Lionhearted (Shelter Edit) Shelter Live 4:19
307 Porter Robinson; Madeon Flicker (Shelter Edit) Shelter Live 2:57
308 Porter Robinson; Madeon Finale x Cut The Kid (Shelter Edit) Shelter Live 4:14
309 Porter Robinson; Madeon Imperium (Shelter Edit) Shelter Live 4:10
310 Porter Robinson; Madeon Pop Culture (Shelter Edit) Shelter Live 1:20
311 Porter Robinson; Madeon Divinity x Innocence (Shelter Edit) Shelter Live 6:20
312 Porter Robinson; Madeon La Lune x Sea Of Voices (Shelter Edit) Shelter Live 2:22
313 Porter Robinson; Madeon Fresh Static Snow (Shelter Edit) Shelter Live 2:22
314 Porter Robinson; Madeon Home (Shelter Edit) Shelter Live 3:47
315 Porter Robinson; Madeon Pixel Empire (Shelter Edit) Shelter Live 2:43
316 Porter Robinson; Madeon Icarus x Fellow Feeling (Shelter Edit) Shelter Live 3:42
317 Porter Robinson; Madeon Goodbye to a World (Shelter Edit) Shelter Live 5:01
318 Porter Robinson; Madeon Encore (Shelter Edit) Shelter Live 5:51
319 projektbau Easy 2 Love Easy 2 Love 1:59
320 Qplex/Maurice Kaar Mountains (Yannek Maunz Remix) Mountains (Yannek Maunz Remix) 6:15
321 Raito Deep Down Inside Deep Down Inside - EP 5:49
322 RAY VOLPE LASERBEAM (ANDEREX LASERBEAM (ANDEREX - Single 3:12
323 Remco Beekwilder LSD - Original Mix LSD EP 5:21
324 Roald Velden & EcueD Moments Gallery:01 (Compiled By Lessov & Roald Velden) 9:15
325 Romina; Franck Dona; Tone Depth No Education - Tone Depth Remix No Education (Tone Depth Remix) 3:51
326 Rosbeh/Paulii Oceans Oceans 4:33
327 Ross from Friends Talk to Me You'll Understand You'll Understand 6:57
328 Rudosa Smash The System No Man's Land EP 7:01
329 Run The Jewels; Zack De La Rocha Close Your Eyes (And Count to Fuck) [feat. Zack De La Rocha] Run the Jewels 2 3:54
330 rxflxct Dreaming Dreaming 4:03
331 salute; なかむらみなみ go! TRUE MAGIC 2:11
332 salute; なかむらみなみ; George Daniel go! - George Daniel Remix go! (George Daniel Remixes) 3:41
333 Salyu; haruka nakamura 星のクズ α 星のクズ α 2:56
334 Sam Silver; ALRT; Yona; Leap96 Downtime Downtime 2:36
335 Sans Soucis/Jacana People Air (feat. Jacana People) (Jacana People Remix) Air (Remixes) 3:25
336 Sasson (FR)/idd aziz/FNX OMAR/Taamy Zani (FNX Omar Remix) Zani 6:25
337 Sebastian Davidson/Cir:cle River Battles River Battles 4:40
338 Seigen Tokuzawa; Masaki Hayashi Iambic 9 Poetry Drift 3:07
339 Shadow Child; Huxley Err Err 5:26
340 Shaolin Cowboy Last Train to Peckham 2Be with You 6:00
341 Shaolin Cowboy All Night All Summer All Night All Summer 4:46
342 SHEE Close My Eyes (Extended Mix) Close My Eyes 6:06
343 SHEE Am I Just Living Miami Ug 2019 6:01
344 Shermanology Boyz N Da Club Boyz N Da Club 6:17
345 Shire T London. Paris. Berlin. Tomorrow’s People 4:05
346 skrillex, porter robinson still here (nuphory x luna lenta lightmode mix) still here (nuphory x luna lenta lightmode mix) - Single 3:56
347 SLIM HUSTLA 2100 Compilation 2 5:48
348 Some Me Engage Engage 6:52
349 Staggr [257757361] hit me + ! [1419656392] cloudwire [2082910431] 1:24
350 Static feel feel 2:54
351 Stereos Summer Girl Stereos (Deluxe Version) 2:45
352 Steve Reich; Kristjan Järvi; Waltraut Wächter; Andreas Hartmann; MDR Leipzig Radio Symphony Orchestra Duet for two Solo Violins and String Orchestra (Dedicated to and written for Yehudi Menuhin) Steve Reich - Duet 5:34
353 Sultan + Shepard; Elderbrook I'll Be Here Endless, Dawn 4:52
354 sumthin sumthin BEEN THRU Growing Pains 3:26
355 sunflwr you got your own thing going you got your own thing going 4:03
356 Sunny Lax; Aneym Everything's A Lie Everything's A Lie 3:40
357 Suulo vs. Curl Navida (Original Mix) Kittball Konspiracy, Vol. 19 7:16
358 Svetec Ruka Hore Ruka Hore 5:16
359 Swimming Paul; Beaux Neptune Good Girl Good Girl 3:14
360 t0ni; Broosnica keepsake Flip keepsake Flip 3:04
361 T78 Ikarus Ikarus 5:05
362 T78; Raito Acid People Acid People / Make Me Feel 6:59
363 tai hirose sink aquatic recitation 3:05
364 tai hirose rain's footsteps deepwater / gauze 2:05
365 tai hirose magnify magnify 1:02
366 Tame Impala Borderline - Blood Orange Remix Borderline 0:12
367 Taylor Swift Love Story (Jake Silva Remix) Love Story (Jake Silva Remix) - Single 4:34
368 TDJ Trpaslik Island SPF INFINI 2 5:11
369 TDJ; fknsyd Euphoria Euphoria 4:14
370 TDJ; Peterparker69 ただdance in the dark ただdance in the dark 4:12
371 Ted Poley; Tony Harnell Escape From The City ...for City Escape SONIC ADVENTURE 2 Original Soundtrack (20th Anniversary Edition) 2:19
372 Tentendo; Jordan Dennis Function Function 3:18
373 Tenth Planet; Vincent de Moor Ghosts - Vincent De Moor Mix Ghosts 6:46
374 Terror Jr; AOBeats Be Some Body Be Some Body 3:01
375 The Knocks; TEED Walking On Water (feat. TEED) HISTORY 3:36
376 thomfjord Intrastellar Intrastellar EP 5:21
377 thomfjord Terminal Velocity Intrastellar EP 5:56
378 thomfjord Day Nite Intrastellar EP
379 Tom Jarmey; Ed Hodge Deeper Pacific Amber Glass 6:42
380 Tommie Sunshine; MureKian; Sabrina Signs; Raito Save the Rave - Raito Remix Save the Rave 4:30
381 Tommy Holohan; Clouds South Beach Burnin Bins (Clouds Remix) RVE002 5:32
382 Too $hort You Came to Party You Came to Party 3:53
383 Tribz/Calixte Sweet Disposition (Vijay & Sofia Edit) Sweet Disposition (Vijay & Sofia Edit) 3:28
384 Tritonal; Lourdiz; Kyau & Albe Love U Right - Kyau & Albert R Love U Right (Remixes, Pt. 1) 2:49
385 Truncate Our Bodies TRUNCEI8HT EP 5:12
386 TWONSKi DiAMONDS DiAMONDS 3:08
387 UMEK Double the Lust - Original Mix Double the Lust 7:40
388 V. Christie Talk To Me Talk To Me 3:53
389 Vanessa Sukowski; ABYSSVM Lucanus Cervus - Abyssvm Remix Lucanus Cervus 7:08
390 Varg²™; Bladee; Skrillex Is there a place in heaven for boys like me? Nordic Flora Series, Pt. 6: Outlaw Music 3:41
391 Varra Catto Observatory - Remix Catto Observatory (Remix) 2:47
392 Veerus Heavy FOUR ON JAM 01 6:52
393 Village C-R-E-W C-R-E-W 3:25
394 Vincent Love Me Now (Extended) Love Me Now (Extended) - Single 4:07
395 Vincent de Moor Fly Away - Extended Vocal Mix Fly Away 8:21
396 VTSS Sober Raving HELLCAT VOL.1 5:38
397 Wassu Escape Tales of Romance I 7:12
398 water feature Pond Bather Various Birds 1:11
399 wes mills; meli this time this time 3:12
400 Willaris. K; jamesjamesjames Silversun Silversun 5:37
401 Willo Past Time Past Time 5:40
402 Worakls Detached Motion (Patrice Bdumel Remix) Hortari & Detached Motion Remixes 6:33
403 Worakls/Ben Bvhmer/eivxr Red Dressed (Ben Bvhmer Remix - Short Version) Red Dressed (Ben Bvhmer Remix) 3:46
404 X-COAST Party Time - Extended Pianissimo 5:46
405 X-COAST The Ultimate - Extended Pianissimo 6:09
406 Yeek Long Time No See Long Time No See 1:51
407 yuragi soon nightlife 2:46
408 yuragi night is young nightlife 5:44
409 Zigan Aldi Heppnar (Iorie Remix) Heppnar (Iorie Remix) 7:22
410 Zmi Shigatsu ame Fu-ne 2:50
411 zodivk yours (+) yours (+) 2:55
412 ​adri; dossyx; Feathervane; Stardreams; wintercolor Atonement STORYBOOK: Chapter 2 6:12
413 ⣎⡇ꉺლ༽இ•̛)ྀ◞ ༎ຶ ༽ৣৢ؞ৢ؞ؖ ꉺლ ƪ. ◖ƪ❍⊁◞. ◗щ (*ㅇ△₊⁎❝᷀ົཽ*ೃ:(꒡͡ ❝᷀ົཽ ꉺ¨.·*:・✧⃛(ཽ๑ )✧⃛*
@@ -0,0 +1,46 @@
# /opt/sldl/configs/artist-canonical.list
#
# Canonical artist-name casing. One name per line; '#' starts a comment.
# Names here are written exactly as Spotify (or the artist) stylizes them.
#
# normalize-artist-casing.py looks up every distinct $albumartist value in the
# beets library; for any value that matches a name here case-insensitively but
# differs in casing, it runs `beet modify` to rewrite the tag to the canonical
# form. beets then moves the album folder to the canonical path.
#
# Purpose: prevents Linux → Windows Syncthing collisions like `Mall Grab/` vs
# `MALL GRAB/` (same path on Windows, different on Linux).
#
# To add an artist:
# 1. Look them up on Spotify, copy their displayed name exactly.
# 2. Append a new line below.
# 3. Run /opt/sldl/lib/normalize-artist-casing.py to preview, then --apply.
#
# Characters illegal on Windows (`<>:"\?*|`) can appear here — beets's `replace:`
# rule substitutes them with `_` when generating the on-disk path, so the tag
# stays Spotify-correct while the folder stays Windows-safe (e.g. `il:lo` tag
# maps to `il_lo/` folder).
Bicep
bôa
Charli xcx
Charlotte de Witte
Crush3d
deadmau5
DJ HEARTSTRING
DjRUM
Doechii
Eprom
FM Forest
il:lo
KETTAMA
Mall Grab
nanobii
Pasocom Music Club
Porter Robinson
Ross from Friends
Shades
Svetec
UMEK
yuragi
YOTTO
+35
View File
@@ -0,0 +1,35 @@
# djmix-albums.txt — albums to include in the djmix.m3u8 exclusion playlist
#
# Each non-comment line is a beet query for one album. Lines without a pipe (|)
# are passed as a single beet query argument. Lines containing | are split on |
# and each part is passed as a separate beet query term (ANDed together).
#
# Use | to combine albumartist and album constraints, e.g.:
# albumartist::Porter Robinson | album:Secret Sky 2021
#
# Single-field lines with spaces work fine as one arg — beet treats the whole
# string after the colon as the value. Do NOT word-split commas-in-names.
#
# To add an album, find the exact name first:
# docker exec soulbeet beet ls -f '$albumartist|$album' 'artist:Name' | sort -u
# Then add a line and run: sudo /opt/sldl/lib/gen-djmix-playlist.sh
# ---- Fred again.. — Live DJ sets ----
album:Apple Music Live: Fred again..
album:Live from Lyon, France, Oct 24, 2025
album:Alexandra Palace, London, Feb 27, 2026
album:Live from Toronto, Canada, Nov 14, 2025
# ---- Porter Robinson — Live performances / DJ sets ----
albumartist::Porter Robinson | album:Secret Sky 2021
albumartist::Porter Robinson | album:Worlds Live at Second Sky 2019
albumartist::Porter Robinson | album:Shelter Live
# ---- Virtual Self b2b G Jones ----
albumartist::Virtual Self | album:Second Sky 2022
# ---- Air2Earth ----
albumartist::^Air2Earth$ | album:Splash House
# ---- G Jones — add more DJ mix albums below ----
# Find album names with: docker exec soulbeet beet ls -f '$albumartist|$album' 'artist:"G Jones"' | sort -u
@@ -0,0 +1,112 @@
Acoustic
Afrobeat
Alternative
Alternative Rock
Ambient
Anime
Black Metal
Bluegrass
Blues
Bossa Nova
Brazilian
Breakbeat
Cantopop
Chicago House
Chill
Classical
Club
Country
Dance
Dancehall
DJ Mix
Death Metal
Deep House
Detroit Techno
Disco
Drum & Bass
Dub
Dubstep
EDM
Electro
Electronic
Emo
Folk
Funk
Future Bass
Game Soundtrack
Garage
Gospel
Goth
Grime
Grindcore
Grunge
Hard Rock
Hardcore
Hardstyle
Heavy Metal
Hip-Hop
Honky-Tonk
House
IDM
Indian
Indie
Indie Pop
Indie Rock
Industrial
J-Dance
J-Idol
J-Pop
J-Rock
Jazz
Jungle
K-Pop
Latin
Lo-Fi
MPB
Mandopop
Metal
Metalcore
Minimal Techno
New Age
OPM
Opera
Pagode
Phonk
Piano
Pop
Pop Film
Pop Rock
Post-Dubstep
Power Pop
Progressive House
Psych Rock
Punk
Punk Rock
R&B
Rap
Reggae
Reggaeton
Rock
Rock & Roll
Rockabilly
Salsa
Samba
Sertanejo
Shoegaze
Show Tunes
Singer-Songwriter
Ska
Soul
Soundtrack
Synth Pop
Synthwave
Tango
Tech House
Techno
Trance
Trap
Trip-Hop
Turkish
UK Garage
Vaporwave
World Music
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# regen.sh — regenerate per-playlist sldl configs from _template.conf.
#
# Source of truth is $ALEMBIC_CONFIG_DIR/pipeline/playlists.json, written by
# the alembic app's playlist_service whenever a playlist is added/edited/
# removed via the UI. This script never edits that source of truth — it only
# renders it. Run automatically by the app before each playlist sync, or
# manually: regen.sh
#
# playlists.json shape: a JSON array of objects, each with at least
# {"name": "...", "spotify_url": "...", "active": true, "no_m3u": false}.
# Inactive playlists still get a .conf rendered (so a manual run is always
# possible) but are skipped by the scheduler.
set -euo pipefail
ALEMBIC_CONFIG_DIR="${ALEMBIC_CONFIG_DIR:-/config}"
MUSIC_DATA_DIR="${MUSIC_DATA_DIR:-/data/music}"
PIPELINE_DIR="${PIPELINE_DIR:-/app/pipeline}"
CONFIG_OUT_DIR="$ALEMBIC_CONFIG_DIR/pipeline"
PLAYLISTS_JSON="$CONFIG_OUT_DIR/playlists.json"
TEMPLATE="$PIPELINE_DIR/configs/_template.conf"
SLDL_DROPBOX_ROOT="$MUSIC_DATA_DIR/sldl-dropbox"
if [[ ! -f "$PLAYLISTS_JSON" ]]; then
echo "ERROR: $PLAYLISTS_JSON not found — nothing to regenerate." >&2
echo " This file is written by the alembic app; run its playlist" >&2
echo " import/seed step first if this is a fresh setup." >&2
exit 1
fi
mkdir -p "$CONFIG_OUT_DIR"
# One "name<TAB>spotify_url" line per playlist, active or not (see header).
python3 -c '
import json, sys
with open(sys.argv[1]) as f:
playlists = json.load(f)
for p in playlists:
print(f"{p[\"name\"]}\t{p[\"spotify_url\"]}")
' "$PLAYLISTS_JSON" | while IFS=$'\t' read -r name url; do
[[ -z "$name" ]] && continue
sed -e "s|PLAYLIST_NAME|${name}|g" \
-e "s|SPOTIFY_URL|${url}|g" \
-e "s|SLDL_DROPBOX_ROOT|${SLDL_DROPBOX_ROOT}|g" \
"$TEMPLATE" > "$CONFIG_OUT_DIR/${name}.conf"
echo "Generated $CONFIG_OUT_DIR/${name}.conf"
done
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
backfill-buy-url.py — stamp the Bandcamp purchase URL onto library files for
albums/tracks already imported from a Bandcamp purchase.
The forward path (sync-bandcamp.py) tags new purchases at download time. This
one-shot covers everything bought *before* that change landed. It:
1. Pulls the current Bandcamp collection (cookies auth) and builds an index
keyed by (album_artist, album) -> release URL. (Bandcamp's embedded ALBUM
tag matches the collection item_title exactly, and ALBUMARTIST matches the
band name — verified against the library.)
2. Walks the FLAC library, reads ALBUMARTIST/ALBUM, and where a match exists,
writes the COMMERCIAL_INFORMATION Vorbis comment (AzuraCast maps this to
the buy_url custom field — see sync-bandcamp.py BUY_URL_TAG).
3. Optionally tells AzuraCast to reprocess each touched file so the custom
field repopulates without waiting for the periodic media scan.
Dry-run by default. Stdlib only (mutagen NOT required — uses metaflac).
Usage:
backfill-buy-url.py --cookies F --state F [--library DIR] [--apply]
[--azuracast-key ID:SECRET] [--azuracast-base URL]
[--station N] [--force]
"""
import sys, os, re, json, time, html, argparse, subprocess
import urllib.request
from http.cookiejar import MozillaCookieJar
from pathlib import Path
COLLECTION_ITEMS_URL = "https://bandcamp.com/api/fancollection/1/collection_items"
UA = "Mozilla/5.0 (X11; Linux x86_64) backfill-buy-url/1.0"
BUY_URL_TAG = "COMMERCIAL_INFORMATION"
# ---- Bandcamp collection ---------------------------------------------------
def opener(jar):
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def get_fan_id(username, jar):
req = urllib.request.Request(f"https://bandcamp.com/{username}",
headers={"User-Agent": UA})
page = html.unescape(opener(jar).open(req, timeout=60).read().decode("utf-8", "replace"))
m = re.search(r'"fan_id"\s*:\s*"?(\d+)', page)
if not m:
sys.exit("[backfill] could not find fan_id (cookies expired / wrong user?)")
return int(m.group(1))
def fetch_collection(fan_id, jar):
items = []
tok = "9999999999::a::"
while True:
body = json.dumps({"fan_id": fan_id, "older_than_token": tok, "count": 100}).encode()
req = urllib.request.Request(
COLLECTION_ITEMS_URL, data=body,
headers={"User-Agent": UA, "Content-Type": "application/json",
"Accept": "application/json", "Referer": "https://bandcamp.com/"})
data = json.loads(opener(jar).open(req, timeout=60).read())
page = data.get("items", [])
items.extend(page)
if not data.get("more_available") or not page:
break
nt = data.get("last_token")
if not nt or nt == tok:
break
tok = nt
return items
# ---- matching --------------------------------------------------------------
def norm(s):
return re.sub(r"\s+", " ", (s or "").strip()).casefold()
def build_index(items):
"""(album_artist, album) -> url. Index both the bare title and the
"<title> - Single" form (single-track purchases get that ALBUM tag)."""
idx = {}
for it in items:
url = it.get("item_url")
band = it.get("band_name")
title = it.get("item_title")
if not (url and band and title):
continue
for album in (title, f"{title} - Single"):
idx.setdefault((norm(band), norm(album)), url)
return idx
def flac_tag(path, name):
r = subprocess.run(["metaflac", f"--show-tag={name}", path],
capture_output=True, text=True)
for line in r.stdout.splitlines():
if "=" in line:
return line.split("=", 1)[1]
return ""
def set_flac_tag(path, name, value):
subprocess.run(["metaflac", f"--remove-tag={name}", path], capture_output=True)
return subprocess.run(["metaflac", f"--set-tag={name}={value}", path],
capture_output=True).returncode == 0
# ---- AzuraCast reprocess ---------------------------------------------------
def az_reprocess(base, key, station, host_paths, log):
"""Find each host path in AzuraCast and batch-reprocess so the custom
field repopulates. host_path -> relative AzuraCast path is library-root
stripped."""
root = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library/"
want = {p[len(root):] for p in host_paths if p.startswith(root)}
headers = {"X-API-Key": key, "Accept": "application/json"}
def get(path):
req = urllib.request.Request(base + path, headers=headers)
return json.loads(urllib.request.urlopen(req, timeout=60).read())
unique_ids, page = [], 1
while True:
d = get(f"/api/station/{station}/files?per_page=500&page={page}")
for row in d["rows"]:
if row["path"] in want:
unique_ids.append(row["unique_id"])
if page >= d["total_pages"]:
break
page += 1
if not unique_ids:
log(" no matching AzuraCast files to reprocess")
return
body = json.dumps({"do": "reprocess", "files": unique_ids}).encode()
req = urllib.request.Request(base + f"/api/station/{station}/files/batch",
data=body, method="PUT",
headers={**headers, "Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=120).read()
log(f" queued reprocess for {len(unique_ids)} AzuraCast file(s)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cookies", required=True)
ap.add_argument("--state", required=True, help="state.json (for reference/logging)")
ap.add_argument("--library", default=f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library")
ap.add_argument("--apply", action="store_true")
ap.add_argument("--force", action="store_true",
help="overwrite an existing buy-link tag")
ap.add_argument("--azuracast-key", default=None)
ap.add_argument("--azuracast-base", default="https://admin.ephemeral.club")
ap.add_argument("--station", type=int, default=1)
ap.add_argument("--username", default=None,
help="Bandcamp username; default read from config.env if present")
args = ap.parse_args()
user = args.username
if not user:
cfg = f"{os.environ.get('ALEMBIC_CONFIG_DIR', '/config')}/pipeline/bandcamp/config.env"
if os.path.exists(cfg):
for line in Path(cfg).read_text().splitlines():
if line.startswith("BANDCAMP_USERNAME="):
user = line.split("=", 1)[1].strip().strip('"')
if not user:
sys.exit("[backfill] --username required (no config.env BANDCAMP_USERNAME)")
print(f"[backfill] mode={'APPLY' if args.apply else 'DRY RUN'} user={user!r}")
jar = MozillaCookieJar(args.cookies)
jar.load(ignore_discard=True, ignore_expires=True)
fan_id = get_fan_id(user, jar)
items = fetch_collection(fan_id, jar)
idx = build_index(items)
print(f"[backfill] collection: {len(items)} items, {len(idx)} match keys")
flacs = list(Path(args.library).rglob("*.flac"))
print(f"[backfill] scanning {len(flacs)} FLAC files...\n")
matched = tagged = already = unmatched = 0
touched = []
for p in flacs:
sp = str(p)
albumartist = flac_tag(sp, "ALBUMARTIST") or flac_tag(sp, "ARTIST")
album = flac_tag(sp, "ALBUM")
url = idx.get((norm(albumartist), norm(album)))
if not url:
unmatched += 1
continue
matched += 1
existing = flac_tag(sp, BUY_URL_TAG)
if existing and not args.force:
already += 1
continue
rel = sp[len(args.library):].lstrip("/")
print(f" + {rel}\n -> {url}")
if args.apply:
if set_flac_tag(sp, BUY_URL_TAG, url):
tagged += 1
touched.append(sp)
else:
print(" ! metaflac write failed")
else:
tagged += 1
touched.append(sp)
print(f"\n[backfill] matched={matched} {'tagged' if args.apply else 'would-tag'}={tagged}"
f" already-had={already} unmatched-files={unmatched}")
if args.apply and touched and args.azuracast_key:
print("[backfill] telling AzuraCast to reprocess touched files...")
az_reprocess(args.azuracast_base, args.azuracast_key, args.station,
touched, lambda m: print(m))
elif not args.apply:
print("[backfill] re-run with --apply to write (add --azuracast-key to reprocess)")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# One-shot backfill: rewrite tags from Spotify for every track currently in the
# beets library that has a grouping:<playlist> tag matching one of our configs.
#
# After the rewrite, sync beets DB from file tags, then move files to clean
# paths based on the new tags, then trigger a Navidrome rescan.
set -u
CONFIGS="${ALEMBIC_CONFIG_DIR:-/config}/pipeline"
LOG="${ALEMBIC_CONFIG_DIR:-/config}/logs/backfill-$(date +%Y%m%d-%H%M%S).log"
RETAG="${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py"
mkdir -p "$(dirname "$LOG")"
echo "[$(date -Iseconds)] === Backfill start ===" | tee -a "$LOG"
TOTAL_TRACKS=0
TOTAL_PLAYLISTS=0
for conf in "$CONFIGS"/*.conf; do
[[ "$conf" == "$CONFIGS/_template.conf" ]] && continue
playlist=$(basename "$conf" .conf)
url=$(sed -n 's/^input *= *//p' "$conf" | tr -d ' ')
cid=$(sed -n 's/^spotify-id *= *//p' "$conf" | tr -d ' ')
csec=$(sed -n 's/^spotify-secret *= *//p' "$conf" | tr -d ' ')
if [[ -z "$url" || -z "$cid" || -z "$csec" ]]; then
echo "[$playlist] SKIP: missing url/creds in conf" | tee -a "$LOG"
continue
fi
# beets runs in-process in this same container, so its reported paths
# need no container-to-host translation.
paths=$(beet ls -f '$path' "grouping:$playlist" 2>/dev/null)
count=$(echo -n "$paths" | grep -c '^' || true)
if [[ "$count" -eq 0 ]]; then
echo "[$playlist] no tracks tagged in beets — skip" | tee -a "$LOG"
continue
fi
echo "[$playlist] retagging $count tracks via Spotify" | tee -a "$LOG"
if echo "$paths" | SPOTIFY_CLIENT_ID="$cid" SPOTIFY_CLIENT_SECRET="$csec" \
python3 "$RETAG" "$url" - >> "$LOG" 2>&1; then
echo "[$playlist] retag OK" | tee -a "$LOG"
TOTAL_PLAYLISTS=$((TOTAL_PLAYLISTS + 1))
TOTAL_TRACKS=$((TOTAL_TRACKS + count))
else
echo "[$playlist] retag FAILED — see $LOG" | tee -a "$LOG"
fi
done
echo "[$(date -Iseconds)] === Retagged $TOTAL_TRACKS tracks across $TOTAL_PLAYLISTS playlists ===" | tee -a "$LOG"
echo "[$(date -Iseconds)] Syncing beets DB from file tags (beet update)..." | tee -a "$LOG"
beet update -F path 2>&1 | tail -20 >> "$LOG"
echo "[$(date -Iseconds)] Moving files into clean paths (beet move)..." | tee -a "$LOG"
beet move 2>&1 | tail -5 >> "$LOG"
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
ND_BASE="${ND_BASE:-http://navidrome:4533}"
ND_USER="${ND_USER:-andrew}"
ND_PASS="${ND_PASS:-}"
echo "[$(date -Iseconds)] Triggering Navidrome full scan" | tee -a "$LOG"
curl -s -G "$ND_BASE/rest/startScan.view" \
--data-urlencode "u=$ND_USER" \
--data-urlencode "p=$ND_PASS" \
--data-urlencode 'v=1.16.0' \
--data-urlencode 'c=backfill' \
--data-urlencode 'f=json' \
--data-urlencode 'fullScan=true' >> "$LOG"
echo "[$(date -Iseconds)] === Backfill complete. Log: $LOG ===" | tee -a "$LOG"
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
build-fingerprint-index.py — build/refresh a Chromaprint fingerprint index of
the beets library at $ALEMBIC_CONFIG_DIR/beets/fingerprints.db.
Reads paths from `beet ls -f '$id|||$path'` (beets now runs in-process in this
same container, so no container-to-host path translation is needed — the path
beets reports is already valid in this container's own filesystem view).
Skips entries whose (path, mtime, size) is unchanged. Parallel fpcalc workers
keep this under ~10 min for a 3k-track library.
Used by import-dj-collection.py to dedup incoming DJ files against the existing
library via acoustid.compare_fingerprints().
Usage:
build-fingerprint-index.py [--workers N] [--limit N]
"""
import argparse
import multiprocessing
import os
import sqlite3
import subprocess
import sys
import time
ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
DB_PATH = f"{ALEMBIC_CONFIG_DIR}/beets/fingerprints.db"
DEFAULT_WORKERS = 8
def init_db(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS fingerprints (
beets_id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
mtime REAL NOT NULL,
size INTEGER NOT NULL,
duration INTEGER NOT NULL,
fingerprint TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_fp_path ON fingerprints(path);
"""
)
conn.commit()
def enumerate_library() -> list[tuple[int, str]]:
"""Return [(beets_id, path), ...] for every track in the library."""
cmd = ["beet", "ls", "-f", "$id|||$path"]
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
items: list[tuple[int, str]] = []
for line in out.splitlines():
line = line.rstrip("\r")
if "|||" not in line:
continue
id_str, path = line.split("|||", 1)
try:
beets_id = int(id_str)
except ValueError:
continue
items.append((beets_id, path))
return items
def fpcalc(host_path: str) -> tuple[int, str] | None:
"""Run fpcalc -raw on a file. Returns (duration_seconds, fingerprint_csv)."""
try:
proc = subprocess.run(
["fpcalc", "-raw", host_path],
capture_output=True,
text=True,
timeout=120,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
if proc.returncode != 0:
return None
duration = 0
fingerprint = ""
for line in proc.stdout.splitlines():
if line.startswith("DURATION="):
try:
duration = int(line.split("=", 1)[1])
except ValueError:
pass
elif line.startswith("FINGERPRINT="):
fingerprint = line.split("=", 1)[1]
if not fingerprint:
return None
return duration, fingerprint
def worker(job: tuple[int, str, float, int]) -> tuple[int, str, float, int, int, str] | None:
beets_id, host_path, mtime, size = job
result = fpcalc(host_path)
if result is None:
return None
duration, fingerprint = result
return beets_id, host_path, mtime, size, duration, fingerprint
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--workers", type=int, default=DEFAULT_WORKERS)
ap.add_argument("--limit", type=int, default=0, help="process at most N new files (0=all)")
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
init_db(conn)
print(f"[index] enumerating library via beets...", flush=True)
library = enumerate_library()
print(f"[index] library has {len(library)} tracks", flush=True)
existing: dict[int, tuple[str, float, int]] = {}
for row in conn.execute("SELECT beets_id, path, mtime, size FROM fingerprints"):
existing[row[0]] = (row[1], row[2], row[3])
jobs: list[tuple[int, str, float, int]] = []
missing_count = 0
for beets_id, host_path in library:
try:
st = os.stat(host_path)
except FileNotFoundError:
missing_count += 1
continue
cur = existing.get(beets_id)
if cur is not None and cur[0] == host_path and abs(cur[1] - st.st_mtime) < 1 and cur[2] == st.st_size:
continue
jobs.append((beets_id, host_path, st.st_mtime, st.st_size))
print(f"[index] {len(jobs)} new/changed tracks to fingerprint "
f"({len(library) - len(jobs) - missing_count} already current, {missing_count} missing on disk)", flush=True)
if args.limit > 0:
jobs = jobs[:args.limit]
print(f"[index] --limit {args.limit} applied; processing {len(jobs)}", flush=True)
if not jobs:
conn.close()
return 0
purge_ids = set(existing.keys()) - {b for b, *_ in library}
if purge_ids:
conn.executemany("DELETE FROM fingerprints WHERE beets_id = ?", [(i,) for i in purge_ids])
conn.commit()
print(f"[index] purged {len(purge_ids)} stale rows", flush=True)
start = time.time()
done = 0
failed = 0
batch: list[tuple] = []
with multiprocessing.Pool(args.workers) as pool:
for result in pool.imap_unordered(worker, jobs, chunksize=4):
done += 1
if result is None:
failed += 1
else:
batch.append(result)
if len(batch) >= 50:
conn.executemany(
"INSERT OR REPLACE INTO fingerprints "
"(beets_id, path, mtime, size, duration, fingerprint) VALUES (?,?,?,?,?,?)",
batch,
)
conn.commit()
batch.clear()
if done % 100 == 0 or args.verbose:
elapsed = time.time() - start
rate = done / elapsed if elapsed > 0 else 0
print(f"[index] {done}/{len(jobs)} ({rate:.1f}/s, {failed} failed)", flush=True)
if batch:
conn.executemany(
"INSERT OR REPLACE INTO fingerprints "
"(beets_id, path, mtime, size, duration, fingerprint) VALUES (?,?,?,?,?,?)",
batch,
)
conn.commit()
total = conn.execute("SELECT COUNT(*) FROM fingerprints").fetchone()[0]
conn.close()
elapsed = time.time() - start
print(f"[index] done in {elapsed:.0f}s. wrote {done - failed} rows, {failed} failed. "
f"index now has {total} entries.", flush=True)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
clean-sldl-index.py — for each playlist's _sldl.m3u8, drop entries whose
track is no longer anywhere in the beets library. sldl uses the m3u as its
"already downloaded" skip list, so anything left in there is skipped on
the next playlist run. By removing entries for files we deleted (placeholders,
quarantined junk), we let sldl retry them fresh.
Conservative matching — we only drop a m3u line if no library track even
loosely matches its (artist, title). If we drop too aggressively, sldl
re-downloads good tracks we still have. If we keep too liberally, deleted
placeholders linger in the m3u — but that's a smaller problem.
Usage:
clean-sldl-index.py # dry run
clean-sldl-index.py --apply # rewrite m3us in place (.bak.<ts> backup)
"""
import sys, re, subprocess, argparse, os, time, shutil
from pathlib import Path
from difflib import SequenceMatcher
DROPBOX = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/sldl-dropbox"
def normalize(s):
"""Lowercase, strip parens content + feat/ft suffix, collapse whitespace.
Keep ALL alphanumeric (including Unicode/Japanese) — strip only spaces and
common punctuation."""
s = s.lower()
s = re.sub(r"\s*\([^)]*\)", "", s)
s = re.sub(r"\s*\[[^\]]*\]", "", s)
s = re.sub(r"\s*feat\.?\s.*", "", s)
s = re.sub(r"\s*ft\.?\s.*", "", s)
s = re.sub(r"[\s\-_,;:&'\"!?.()\[\]/]+", "", s)
return s
def fetch_beets_index():
"""Return list of (norm_artist_title, norm_artist, norm_title) tuples for
every track in beets, normalized for comparison."""
r = subprocess.run(
["beet", "ls", "-f", "$artist‖$title"],
capture_output=True, text=True, timeout=120
)
out = []
for line in r.stdout.splitlines():
if "" not in line:
continue
artist, title = line.split("", 1)
n_a = normalize(artist)
n_t = normalize(title)
out.append((n_a + n_t, n_a, n_t))
return out
def parse_m3u_line(line):
"""Extract (artist, title) from a m3u filename like 'Artist - Title.ext'.
If the line is FAIL-prefixed or comment, return (None, None)."""
s = line.rstrip("\n")
if not s or s.startswith("#"):
return None, None
stem = re.sub(r"\.[a-zA-Z0-9]{1,5}$", "", s)
if " - " in stem:
# First " - " split: typical "Artist - Title"
a, _, t = stem.partition(" - ")
return a.strip(), t.strip()
return None, stem
def m3u_matches_beets(line, beets_idx):
"""Return True if some beets track plausibly matches this m3u line."""
artist, title = parse_m3u_line(line)
if artist is None and title is None:
return True # comment/blank — keep
n_a = normalize(artist or "")
n_t = normalize(title or "")
n_full = n_a + n_t
if not n_full:
return True # nothing to match against — be safe and keep
for beets_full, beets_a, beets_t in beets_idx:
if not beets_full:
continue
# Cheap exact full match
if n_full == beets_full:
return True
# Mutual substring (handles multi-artist credits added to one side)
if n_full in beets_full or beets_full in n_full:
return True
# Title-only match if artist sides are very different (multi-artist case)
if n_t and beets_t and (n_t == beets_t or n_t in beets_t or beets_t in n_t):
# require some artist overlap (substring either way) to avoid false hits
if n_a and beets_a and (n_a in beets_a or beets_a in n_a):
return True
# Romaji ↔ Japanese cases: the m3u uses Spotify's romaji artist
# ("natori") but the file has the original kanji ("なとり"). Exact
# title match with a non-trivial length is enough.
if n_t == beets_t and len(n_t) > 6:
return True
# Last-ditch fuzzy: very similar overall strings
if len(n_full) > 6 and len(beets_full) > 6:
ratio = SequenceMatcher(None, n_full, beets_full).quick_ratio()
if ratio > 0.85:
return True
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
print(f"[clean-index] mode={'APPLY' if args.apply else 'DRY RUN'}")
print("[clean-index] loading beets library index...")
beets_idx = fetch_beets_index()
print(f"[clean-index] indexed {len(beets_idx)} tracks")
# Safety guard: an empty index means `beet ls` failed (missing/locked DB,
# bad config) — NOT that the library is empty. Without this check,
# --apply would drop every entry from every _sldl.m3u8 and the next
# playlist runs would re-download the entire library from Soulseek.
if not beets_idx:
print("[clean-index] ABORT: beets index came back empty "
"(beet ls failed) — refusing to touch any m3u",
file=sys.stderr)
return 1
total_kept = 0
total_dropped = 0
total_files = 0
for m3u in sorted(Path(DROPBOX).glob("*/_sldl.m3u8")):
playlist = m3u.parent.name
with open(m3u) as f:
lines = f.readlines()
kept, dropped = [], []
for line in lines:
if m3u_matches_beets(line, beets_idx):
kept.append(line)
else:
dropped.append(line)
print(f"\n[{playlist}] {len(kept)} kept, {len(dropped)} dropped")
for d in dropped[:8]:
print(f" DROP {d.rstrip()}")
if len(dropped) > 8:
print(f" ...and {len(dropped) - 8} more")
total_kept += len(kept)
total_dropped += len(dropped)
total_files += 1
if args.apply and dropped:
backup = f"{m3u}.bak.{int(time.time())}"
shutil.copy(m3u, backup)
with open(m3u, "w") as f:
f.writelines(kept)
print(f" [{playlist}] rewritten; backup at {backup}")
print(f"\n[clean-index] {total_files} m3us, total {total_kept} kept, {total_dropped} dropped")
if not args.apply:
print("[clean-index] DRY RUN — re-run with --apply to rewrite the m3us")
if __name__ == "__main__":
sys.exit(main())
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
clear-bad-genres.py — normalize or blank GENRE tags so every file ends up
with a clean "; "-joined list of whitelist entries (max 3 parts).
Two-tier behavior per file:
1. NORMALIZE: if every part of the GENRE value canonicalizes to a
whitelist entry (after splitting on ; , / or " - " and resolving aliases
like "Rnb""R&B", "Hip Hop""Hip-Hop"), and there are ≤3 parts,
REWRITE the file's GENRE in canonical "Display1; Display2" form. This
fixes formatting issues like "Dance;Pop" (no space) → "Dance; Pop"
without losing the data.
2. BLANK: if any part doesn't map to a whitelist canonical, OR there are
too many parts, BLANK the GENRE field. Run spotify-genre.py --apply
--force afterwards to refill what Spotify can.
Catches:
- Mojibake (Cyrillic-as-ASCII like '5:B@>==0O <C7K:0')
- Wrong delimiters: comma ('Pop, Rock'), slash ('Indie Rock/Rock Pop'),
space-dash-space ('Dance - Electro - Vocal'), missing space after
semicolon ('Dance;Pop')
- Mixed-delimiter chains ('Pop, Rock; Alternative')
- Bracketed values ('[Drum n Bass]')
- Over-tagging: more than 3 parts (matches spotify-genre.py's MAX_GENRES)
- Spelling variants: 'Rnb''R&B', 'Hip Hop''Hip-Hop',
'Drum n Bass' / 'Drum and Bass''Drum & Bass', 'K Pop''K-Pop'
- Niche values not in the whitelist
Already-clean tracks are left untouched (zero file writes).
Usage:
clear-bad-genres.py # dry run, show planned normalize+blank ops
clear-bad-genres.py --apply # actually rewrite/blank
"""
import sys, os, re, argparse, subprocess
from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError, TCON
from mutagen.flac import FLAC
from mutagen.mp4 import MP4
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
# OGG/OPUS use Vorbis-comment-style tags (lowercase keys, multi-value), same
# shape as FLAC. Treat them as a family so the read/write paths are shared.
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
WHITELIST_FILE = f"{ALEMBIC_CONFIG_DIR}/pipeline/genres-whitelist.txt"
MAX_PARTS = 3 # mirrors spotify-genre.py's MAX_GENRES
SEPARATOR = "; "
# Map normalized variant → normalized whitelist entry. Spotify and various
# Soulseek uploaders use spelling variants of the same genre — we want all of
# them to land on a single canonical whitelist string.
ALIASES = {
"rnb": "rb", # Rnb → R&B
"randb": "rb", # R and B
"hiphop": "hiphop", # noop after normalization
"hiphopalt": "hiphop",
"hphop": "hiphop",
"drumnbass": "drumbass", # Drum n Bass → Drum & Bass
"drumandbass": "drumbass", # Drum and Bass
"dnb": "drumbass", # DnB / D&B
"kpop": "kpop",
"jpop": "jpop",
"trance": "trance",
"idm": "idm",
"edm": "edm",
"ukgarage": "ukgarage",
"rocknroll": "rockroll", # Rock n Roll → Rock & Roll
"rockandroll": "rockroll",
"popfilm": "popfilm",
"popsoundtrack": "popfilm",
}
def normed(s):
"""lowercase + strip whitespace/hyphens/underscores/&/slashes/dots."""
return re.sub(r"[\s\-_&/.]+", "", s).lower()
def load_whitelist():
"""Return dict: normalized form → canonical display string from the file."""
out = {}
with open(WHITELIST_FILE) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
out[normed(line)] = line
return out
# Splits on: ; , / space-dash-space (any of these = multi-genre delimiter)
SPLIT_RE = re.compile(r"\s+-\s+|[;,/]")
def canonicalize(value, wl):
"""Try to parse value into ≤MAX_PARTS canonical whitelist entries.
Returns:
(parts_canonical, all_mapped) where:
parts_canonical = list of whitelist display strings (deduped, original
order), possibly empty
all_mapped = True iff every parsed part landed on a whitelist entry
AND the part count is within MAX_PARTS
"""
raw_parts = [p.strip(" \t[]()<>") for p in SPLIT_RE.split(value)]
raw_parts = [p for p in raw_parts if p]
if not raw_parts or len(raw_parts) > MAX_PARTS:
return [], False
seen = set()
out = []
for p in raw_parts:
n = normed(p)
# Try alias first, then direct whitelist hit
canon_norm = ALIASES.get(n, n)
display = wl.get(canon_norm)
if not display:
return [], False
if display not in seen:
out.append(display)
seen.add(display)
return out, True
def assess(value, wl):
"""Return ('clean'|'rewrite'|'blank', new_value).
- 'clean' : current value already canonical, no write needed
- 'rewrite' : all parts map cleanly; rewrite file as new_value
- 'blank' : at least one part is junk; blank the field
"""
if not value or not value.strip():
return "clean", None
canon_parts, ok = canonicalize(value, wl)
if not ok:
return "blank", None
new_value = SEPARATOR.join(canon_parts)
if new_value == value:
return "clean", None
return "rewrite", new_value
def is_genre_locked(m):
"""True if the file has GENRE_LOCK=1 (set by fix-genre.sh).
Manually-overridden genres often won't match the whitelist (e.g. 'Bass'
isn't in the whitelist but G Jones genuinely needs it). The lock means
'this is intentional, leave it alone'."""
if not m or not m.tags:
return False
try:
if isinstance(m, VORBIS_LIKE):
val = (m.tags.get("genre_lock") or m.tags.get("GENRE_LOCK") or [None])[0]
return val == "1"
if isinstance(m, MP4):
return False # fix-genre.sh doesn't set lock on MP4 yet
for frame in m.tags.getall("TXXX"):
if frame.desc.upper() == "GENRE_LOCK" and frame.text and frame.text[0] == "1":
return True
except (AttributeError, IndexError, TypeError):
pass
return False
def get_genre(filepath):
"""Return (mutagen_obj, joined_genre_string).
Vorbis comments (FLAC) and ID3v2.4 TCON both allow multiple GENRE values
per file. Beets joins them with '; ' for display. We mirror that so a file
with 8 separate GENRE tags is correctly seen as an 8-part chain (and gets
flagged as over-tagged), not just whatever the first tag happened to be.
"""
try:
m = MFile(filepath)
except Exception:
return None, None
if not m or not m.tags:
return m, None
if isinstance(m, VORBIS_LIKE):
vals = m.tags.get("genre") or []
return m, ("; ".join(str(v) for v in vals) if vals else None)
if isinstance(m, MP4):
vals = m.tags.get("©gen") or []
return m, ("; ".join(str(v) for v in vals) if vals else None)
if "TCON" in m.tags and m.tags["TCON"].text:
vals = m.tags["TCON"].text
return m, "; ".join(str(v) for v in vals)
return m, None
def clear_genre(filepath, m):
try:
if isinstance(m, VORBIS_LIKE):
if "genre" in m.tags:
del m.tags["genre"]
m.save()
elif isinstance(m, MP4):
if "©gen" in m.tags:
del m.tags["©gen"]
m.save()
else:
tags = ID3(filepath)
tags.delall("TCON")
tags.save(filepath)
return True
except (AttributeError, ID3NoHeaderError, Exception):
return False
def write_genre(filepath, m, value):
"""Overwrite GENRE with a single string value (replacing any multi-value
set the file might have had — that's the whole point of this rewrite)."""
try:
if isinstance(m, VORBIS_LIKE):
m.tags["genre"] = [value]
m.save()
elif isinstance(m, MP4):
m.tags["©gen"] = [value]
m.save()
else:
try:
tags = ID3(filepath)
except ID3NoHeaderError:
tags = ID3()
tags.delall("TCON")
tags.add(TCON(encoding=3, text=value))
tags.save(filepath)
return True
except (AttributeError, ID3NoHeaderError, Exception):
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
wl = load_whitelist()
print(f"[clear-bad-genres] whitelist has {len(wl)} entries")
# beets now runs in-process in this same container, so the paths it
# reports are already valid here directly — no container-to-host
# translation needed.
r = subprocess.run(
["beet", "ls", "-f", "$path"],
capture_output=True, text=True, timeout=120
)
paths = [p for p in r.stdout.splitlines() if p.strip()]
print(f"[clear-bad-genres] {len(paths)} tracks to scan")
rewritten = blanked = failed = locked = 0
rewrite_examples = {} # original value -> (sample filename, canonical form)
blank_examples = {} # original value -> sample filename
for p in paths:
if not os.path.exists(p):
continue
m, current = get_genre(p)
if m is None or current is None:
continue
if is_genre_locked(m):
locked += 1
continue
action, new_value = assess(current, wl)
if action == "clean":
continue
if action == "rewrite":
if current not in rewrite_examples:
rewrite_examples[current] = (os.path.basename(p), new_value)
if args.apply:
if write_genre(p, m, new_value):
rewritten += 1
else:
failed += 1
else:
rewritten += 1
else: # blank
if current not in blank_examples:
blank_examples[current] = os.path.basename(p)
if args.apply:
if clear_genre(p, m):
blanked += 1
else:
failed += 1
else:
blanked += 1
verb = "rewrote" if args.apply else "would-rewrite"
bverb = "blanked" if args.apply else "would-blank"
print(f"\n[clear-bad-genres] {verb}: {rewritten} tracks, {bverb}: {blanked} tracks, "
f"locked-skipped: {locked}, failed: {failed}")
if rewrite_examples:
print(f"\nNormalize-rewrites ({len(rewrite_examples)} distinct):")
for old, (sample, new) in sorted(rewrite_examples.items())[:50]:
print(f" {old!r:40s}{new!r:30s} e.g. {sample}")
if len(rewrite_examples) > 50:
print(f" ...and {len(rewrite_examples)-50} more")
if blank_examples:
print(f"\nBlanked (will be refilled by spotify-genre) ({len(blank_examples)} distinct):")
for value, sample in sorted(blank_examples.items())[:50]:
print(f" {value!r:60s} e.g. {sample}")
if len(blank_examples) > 50:
print(f" ...and {len(blank_examples)-50} more")
if args.apply and (rewritten or blanked):
print("\n[clear-bad-genres] now run:")
print(" beet update")
if blanked:
print(" spotify-genre.py --apply --force")
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Convert m4a files in-place: AAC -> MP3 (V0 VBR), ALAC -> FLAC (lossless).
# Preserves metadata + embedded cover art. Deletes original on success.
set -u
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/convert-m4a-$(date +%Y%m%d-%H%M%S).log
mkdir -p "$(dirname "$LOG")"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
OK_AAC=0
OK_ALAC=0
FAIL=0
while IFS= read -r -d '' f; do
codec=$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "$f" 2>/dev/null | tr -d ',')
base="${f%.m4a}"
case "$codec" in
aac)
out="${base}.mp3"
if [[ -e "$out" ]]; then log "SKIP-EXISTS: $out"; FAIL=$((FAIL+1)); continue; fi
if ffmpeg -nostdin -hide_banner -loglevel error -i "$f" \
-map 0 -c:v copy -c:a libmp3lame -q:a 0 -id3v2_version 3 \
"$out" 2>>"$LOG"; then
rm -f "$f"
log "AAC→MP3: $out"
OK_AAC=$((OK_AAC+1))
else
log "FAIL-AAC: $f"
rm -f "$out" 2>/dev/null
FAIL=$((FAIL+1))
fi
;;
alac)
out="${base}.flac"
if [[ -e "$out" ]]; then log "SKIP-EXISTS: $out"; FAIL=$((FAIL+1)); continue; fi
if ffmpeg -nostdin -hide_banner -loglevel error -i "$f" \
-map 0 -c:v copy -c:a flac -compression_level 8 \
"$out" 2>>"$LOG"; then
rm -f "$f"
log "ALAC→FLAC: $out"
OK_ALAC=$((OK_ALAC+1))
else
log "FAIL-ALAC: $f"
rm -f "$out" 2>/dev/null
FAIL=$((FAIL+1))
fi
;;
*)
log "SKIP-OTHER ($codec): $f"
;;
esac
done < <(find ${MUSIC_DATA_DIR:-/data/music}/Library -type f -iname "*.m4a" -print0 2>/dev/null)
log ""
log "=== AAC→MP3: $OK_AAC | ALAC→FLAC: $OK_ALAC | Failed: $FAIL ==="
log "Log: $LOG"
+461
View File
@@ -0,0 +1,461 @@
#!/bin/bash
# Walk the beets library for duplicates using three passes:
#
# Pass 1 — numbered siblings: find every *.N.ext in beets where *.ext also
# exists on disk in the same directory; keep the better file.
#
# Pass 2 — case-insensitive beet-level dedup: pull all tracks from beets,
# group by lowercase (albumartist, album, title); any group with
# 2+ members is a duplicate set — keep FLAC > MP3 > largest file.
#
# Pass 3 — normalized beet-level dedup: same as Pass 2 but the key strips
# all non-alphanumeric characters before grouping. Catches dupes
# whose tags differ only by separator/punctuation/feat. credit
# ("Vol. 7" vs ", Vol. 7"; "Artist1; Artist2" vs "Artist1/Artist2").
#
# "Best" is always: FLAC > MP3/other, then largest file within the same format.
#
# DRY RUN by default. Pass --apply to actually delete.
set -u
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/dedup-$(date +%Y%m%d-%H%M%S).log
mkdir -p "$(dirname "$LOG")"
log() { echo "$*" | tee -a "$LOG"; }
if [[ $APPLY -eq 1 ]]; then
log "[$(date -Iseconds)] === DEDUP (APPLY) ==="
else
log "[$(date -Iseconds)] === DEDUP (DRY RUN — pass --apply to delete) ==="
fi
# Counters: tracked via log grep at the end (avoids bash subshell pitfalls).
# Functions write KEEP/DELETE lines with consistent prefixes; summary greps them.
# Convert container path (/music/...) to host path (${MUSIC_DATA_DIR:-/data/music}/Library/...)
host_path() { echo "${MUSIC_DATA_DIR:-/data/music}/Library${1#/music}"; }
SEP=$'\x1c' # ASCII file separator — safe with any music metadata
# Rank a file: lower = better. FLAC > MP3 > other (WAV/etc.); clean filename
# > sync-conflict artifact (-DESKTOP-, (1), .1.); extended/club mix > radio
# edit at same format; then larger wins.
#
# WAV ranks below MP3 deliberately: WAV has no real tag container and in this
# library only appears as a sldl download glitch. Keeping a large WAV over a
# tagged MP3 of the same track is never what we want.
rank_file() {
local hp="$1"
local size; size=$(stat -c '%s' "$hp" 2>/dev/null || echo 0)
local ext_score=0
[[ "${hp,,}" == *.flac ]] && ext_score=2
[[ "${hp,,}" == *.mp3 ]] && ext_score=1
local dirty_score=0
# (N) copy-artifact check is capped at 2 digits: "(1).flac" is a Windows
# copy artifact, but "(2014).flac" is a title ending in a year — marking
# those dirty inverted the keep preference toward their .1.flac siblings.
if [[ "$hp" =~ -DESKTOP-[A-Z0-9]+ ]] \
|| [[ "$hp" =~ \([0-9]{1,2}\)\.[a-z]+$ ]] \
|| [[ "$hp" =~ \.[0-9]+\.[a-z]+$ ]]; then
dirty_score=1
fi
local extended_score=0
shopt -s nocasematch
if [[ "$hp" =~ (extended|club\ mix|dj\ edit|dj\ mix|long\ version|original\ mix) ]]; then
extended_score=1
fi
shopt -u nocasematch
echo $((10000000000 - ext_score * 1000000000000 + dirty_score * 100000000000 - extended_score * 50000000000 - size))
}
# process_group LABEL ENTRY [ENTRY ...]
# ENTRY is either "<beets-id>\x1c<container-path>" (a track in beets — deleted
# via `beet remove id:N`) or a bare host path (a ghost file on disk only;
# rm-only to delete).
#
# Why id queries, not path queries: the beets 2.11 upgrade (~2026-06-19) left
# the DB with MIXED path storage — pre-upgrade items store absolute
# /music/... paths, post-upgrade imports store library-relative paths. No
# single path:/path:: query form matches both populations (this is what made
# every dedup DELETE a silent no-op for two weeks). Item ids are storage-
# format-proof.
process_group() {
local label="$1"; shift
local ranked=""
local hp id entry
for entry in "$@"; do
[[ -z "$entry" ]] && continue
if [[ "$entry" == *"$SEP"* ]]; then
id="${entry%%"$SEP"*}"
hp=$(host_path "${entry#*"$SEP"}")
else
id=""
hp="$entry"
fi
[[ -f "$hp" ]] || continue
local score; score=$(rank_file "$hp")
ranked+="${score}${SEP}${id}${SEP}${hp}"$'\n'
done
[[ -z "$ranked" ]] && return 1 # no files on disk — skip
log ""
log "--- $label ---"
local first=1
while IFS="$SEP" read -r _sk id hp; do
[[ -z "$hp" ]] && continue
local size_h; size_h=$(numfmt --to=iec --suffix=B "$(stat -c '%s' "$hp" 2>/dev/null || echo 0)")
if [[ $first -eq 1 ]]; then
log " KEEP ($size_h) $hp"
first=0
else
log " DELETE ($size_h) $hp"
if [[ $APPLY -eq 1 ]]; then
if [[ -n "$id" ]]; then
# In beets — beet remove -d removes from DB + disk.
beet remove -d -f "id:${id}" >> "$LOG" 2>&1
else
# Ghost file — only on disk; just rm
rm -f "$hp" 2>>"$LOG"
fi
fi
fi
done < <(echo "$ranked" | grep -v '^$' | sort -n)
}
# ============================================================
# Pass 1: numbered siblings
# Find every *.N.ext in beets where *.ext also exists on disk.
# ============================================================
log ""
log "[$(date -Iseconds)] === Pass 1: numbered siblings ==="
# Dump id + container path from beets once. beets normalizes $path for
# display (always /music/-prefixed) even though the DB stores a mix of
# absolute and relative — so the display paths are safe to compare/convert,
# and the ids are what we hand to beet remove/move.
beet ls -f "\$id${SEP}\$path" 2>/dev/null > /tmp/beets-id-paths.txt
cut -d"$SEP" -f2- /tmp/beets-id-paths.txt > /tmp/beets-all-paths.txt
while IFS="$SEP" read -r numbered_id numbered_cp; do
[[ -z "$numbered_cp" ]] && continue
ext="${numbered_cp##*.}"
canonical_cp=$(echo "$numbered_cp" | sed -E "s/\.[0-9]+\.${ext}$/.${ext}/")
[[ "$canonical_cp" == "$numbered_cp" ]] && continue # no .N. found
host_numbered=$(host_path "$numbered_cp")
host_canonical=$(host_path "$canonical_cp")
[[ -f "$host_numbered" ]] || continue
[[ -f "$host_canonical" ]] || continue
# If the canonical is also in beets, Pass 2 handles this pair via beet remove -d.
# Skip here to avoid a partial rm that leaves a stale beets DB entry.
grep -qxF "$canonical_cp" /tmp/beets-all-paths.txt && continue
score_numbered=$(rank_file "$host_numbered")
score_canonical=$(rank_file "$host_canonical")
if [[ $score_canonical -le $score_numbered ]]; then
# Canonical wins: delete numbered (in beets), keep canonical (ghost)
process_group "P1 numbered-sibling: ${numbered_cp##/music/}" \
"$host_canonical" "${numbered_id}${SEP}${numbered_cp}"
if [[ $APPLY -eq 1 ]]; then
# canonical is now a ghost file; import it into beets
beet import -q -s "${canonical_cp}" >> "$LOG" 2>&1
fi
else
# Numbered wins (canonical is ghost): rm the ghost, then beet move to rename
# the numbered file to the canonical path (id query — see process_group).
process_group "P1 numbered-sibling: ${numbered_cp##/music/}" \
"${numbered_id}${SEP}${numbered_cp}" "$host_canonical"
if [[ $APPLY -eq 1 ]]; then
beet move "id:${numbered_id}" >> "$LOG" 2>&1
fi
fi
done < <(grep -E '\.[0-9]+\.(flac|mp3|m4a|ogg|opus|wav)$' /tmp/beets-id-paths.txt)
P1_GROUPS=$(grep -c '^--- P1 ' "$LOG" 2>/dev/null; true)
log ""
log "Pass 1 complete: $P1_GROUPS numbered-sibling groups"
# ============================================================
# Pass 2: case-insensitive beet-level dedup
# Re-fetch track list so Pass 1 deletions are reflected.
# ============================================================
log ""
log "[$(date -Iseconds)] === Pass 2: case-insensitive title/album/artist dedup ==="
beet ls -f "\$id${SEP}\$albumartist${SEP}\$album${SEP}\$title${SEP}\$path" \
2>/dev/null > /tmp/all-tracks.txt
awk -F"${SEP}" -v sep="$SEP" '
# Sentinel titles carry zero identity — multiple distinct tracks can land on
# the same value when spotify-retag misses and the Soulseek source had a
# blank/placeholder title. Grouping by sentinel title = deleting different
# tracks that happened to share a placeholder. Common offenders:
# "" fully empty title
# "[unknown]" "unknown" sldl/beets fallback
# "track 1" .. "track NN" stock CD-rip placeholder
function is_sentinel_title(t, lt) {
lt = tolower(t)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", lt)
if (lt == "") return 1
if (lt == "[unknown]" || lt == "unknown") return 1
if (lt ~ /^track[[:space:]]*[0-9]+$/) return 1
return 0
}
{
if (is_sentinel_title($4)) next
key = tolower($2) sep tolower($3) sep tolower($4)
paths[key] = paths[key] $1 sep $5 "\n"
count[key]++
}
END {
for (k in paths) {
if (count[k] >= 2) print "===" k "\n" paths[k] "---"
}
}' /tmp/all-tracks.txt > /tmp/dups-grouped.txt
GROUP_KEY=""
GROUP_PATHS=()
flush_p2_group() {
[[ ${#GROUP_PATHS[@]} -lt 2 ]] && { GROUP_PATHS=(); return; }
process_group "P2 case-insensitive: $GROUP_KEY" "${GROUP_PATHS[@]}"
GROUP_PATHS=()
}
while IFS= read -r line; do
if [[ "$line" == ===* ]]; then
flush_p2_group
GROUP_KEY="${line#===}"
GROUP_PATHS=()
elif [[ "$line" == "---" ]]; then
flush_p2_group
elif [[ -n "$line" ]]; then
GROUP_PATHS+=("$line")
fi
done < /tmp/dups-grouped.txt
flush_p2_group # flush last group
P2_GROUPS=$(grep -c '^--- P2 ' "$LOG" 2>/dev/null; true)
log ""
log "Pass 2 complete: $P2_GROUPS case-insensitive dup groups"
# ============================================================
# Pass 3: normalized-key dedup (strip non-alphanumerics)
# Re-fetch track list so Pass 2 deletions are reflected.
# ============================================================
log ""
log "[$(date -Iseconds)] === Pass 3: normalized title/album/artist dedup ==="
beet ls -f "\$id${SEP}\$albumartist${SEP}\$album${SEP}\$title${SEP}\$path" \
2>/dev/null > /tmp/all-tracks.txt
# Normalize: lowercase + strip everything except a-z 0-9. Group by the
# concatenation. Suppresses dupe pairs already caught by Pass 2 (same exact
# key) by also tracking the raw key per group and skipping single-key groups.
awk -F"${SEP}" -v sep="$SEP" '
function norm(s, t) {
t = tolower(s)
gsub(/[^a-z0-9]/, "", t)
return t
}
function is_sentinel_title(t, lt) {
lt = tolower(t)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", lt)
if (lt == "") return 1
if (lt == "[unknown]" || lt == "unknown") return 1
if (lt ~ /^track[[:space:]]*[0-9]+$/) return 1
return 0
}
{
if (is_sentinel_title($4)) next
raw = tolower($2) sep tolower($3) sep tolower($4)
na = norm($2); nb = norm($3); nc = norm($4)
# Title is the only field guaranteed unique per track. If it normalizes
# to empty (all unicode/punctuation), refuse to group — would cause false
# positives across unrelated unicode-titled tracks. A 1-char normalized
# title is fine as long as artist+album also normalize non-empty
# (covers "ID"/"OP"/single-letter tracks; rejects all-unicode titles
# that collapse to "").
if (length(nc) == 0) next
if (length(nc) < 3 && (length(na) == 0 || length(nb) == 0)) next
k = na sep nb sep nc
paths[k] = paths[k] $1 sep $5 "\n"
count[k]++
raws[k][raw] = 1
}
END {
for (k in paths) {
if (count[k] < 2) continue
# Skip groups that collapse to a single raw key — those were Pass 2 dups.
raw_count = 0
for (r in raws[k]) raw_count++
if (raw_count < 2) continue
print "===" k "\n" paths[k] "---"
}
}' /tmp/all-tracks.txt > /tmp/dups-grouped.txt
GROUP_KEY=""
GROUP_PATHS=()
flush_p3_group() {
[[ ${#GROUP_PATHS[@]} -lt 2 ]] && { GROUP_PATHS=(); return; }
process_group "P3 normalized: $GROUP_KEY" "${GROUP_PATHS[@]}"
GROUP_PATHS=()
}
while IFS= read -r line; do
if [[ "$line" == ===* ]]; then
flush_p3_group
GROUP_KEY="${line#===}"
GROUP_PATHS=()
elif [[ "$line" == "---" ]]; then
flush_p3_group
elif [[ -n "$line" ]]; then
GROUP_PATHS+=("$line")
fi
done < /tmp/dups-grouped.txt
flush_p3_group # flush last group
P3_GROUPS=$(grep -c '^--- P3 ' "$LOG" 2>/dev/null; true)
log ""
log "Pass 3 complete: $P3_GROUPS normalized dup groups"
# ============================================================
# Pass 4: cross-album dedup (album-agnostic key)
# Catches cases where the same track exists once as a single and once on a
# compilation/Beatport-style album — the album field always disagrees, so
# Pass 2/3 miss them. Keys on (artist_norm, title_base_norm) where title_base
# strips trailing mix-version suffixes ("(Extended Mix)", " - Radio Edit",
# bare " Extended Mix" with double-space, etc.).
#
# Allowlist: artists who legitimately have the same track on multiple albums
# (live + studio + remix etc.) — these are SKIPPED in Pass 4. Add to
# CROSS_ALBUM_KEEP_RE or the album-pattern skip below if needed.
# ============================================================
log ""
log "[$(date -Iseconds)] === Pass 4: cross-album dedup (artist+title-base) ==="
beet ls -f "\$id${SEP}\$albumartist${SEP}\$album${SEP}\$title${SEP}\$path" \
2>/dev/null > /tmp/all-tracks.txt
# Pass artist + album allowlists into awk via -v
CROSS_ALBUM_KEEP_RE='^(porterrobinson|madeon|variousartists)$'
# Album-name patterns that indicate "intentional alt version" — these rows are
# filtered out before grouping, so any group needing 2+ matches will only form
# from items whose albums DON'T look like remix/live/single-version releases.
# Tagging convention: when the user has multiple legitimate versions of a
# track, the alt-version copies live on albums with explicit version markers
# in the album title ("(Extended Mix) - Single", "(Remixed)", "Live at X").
# Without this filter, Pass 4 would try to dedupe Qrion's "Proud" off the
# "I Hope It Lasts Forever (Remixed)" album, Fred again._ studio tracks
# against "Apple Music Live" recordings, etc.
ALBUM_KEEP_RE='(soundtrack|originalmotionpicture|ost|vgm|gameost|liveat|livein|livefrom|livesession|applemusiclive|applelive|bbclive|bbcsession|mtvunplugged|unplugged|concert|inconcert|extended|remix|remixed|remixes|originalmix|radioedit|radiomix|clubmix|djedit|djmix|dubmix|vocalmix|instrumentalmix|longversion|shortversion|albumversion|originalversion)'
awk -F"${SEP}" -v sep="$SEP" \
-v artist_keep="$CROSS_ALBUM_KEEP_RE" \
-v album_keep="$ALBUM_KEEP_RE" '
function norm(s, t) {
t = tolower(s)
gsub(/[^a-z0-9]/, "", t)
return t
}
# Strip trailing mix-version suffix from title so "Foo" and "Foo (Extended Mix)"
# share the same base. Only strips generic version markers — does NOT strip
# remixer-named suffixes like "(Joris Voorn Remix)", which are different songs.
function is_sentinel_title(t, lt) {
lt = tolower(t)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", lt)
if (lt == "") return 1
if (lt == "[unknown]" || lt == "unknown") return 1
if (lt ~ /^track[[:space:]]*[0-9]+$/) return 1
return 0
}
function strip_mix_suffix(s, t, sfx) {
t = tolower(s)
sfx = "(extended[[:space:]]*mix|extended[[:space:]]*version|extended|" \
"radio[[:space:]]*edit|radio[[:space:]]*mix|radio[[:space:]]*version|" \
"original[[:space:]]*mix|original[[:space:]]*version|" \
"club[[:space:]]*mix|dj[[:space:]]*edit|dj[[:space:]]*mix|" \
"long[[:space:]]*version|short[[:space:]]*version|" \
"single[[:space:]]*version|album[[:space:]]*version)"
# parenthetical: " (Extended Mix)"
t = gensub("[[:space:]]*\\(" sfx "\\)[[:space:]]*$", "", "g", t)
# dash-separated: " - Extended Mix"
t = gensub("[[:space:]]*-[[:space:]]+" sfx "[[:space:]]*$", "", "g", t)
# bare trailing: " Extended Mix"
t = gensub("[[:space:]]+" sfx "[[:space:]]*$", "", "g", t)
return t
}
{
id = $1; artist = $2; album = $3; title = $4; path = $5
if (is_sentinel_title(title)) next
na = norm(artist)
if (na ~ artist_keep) next
if (norm(album) ~ album_keep) next
tb = strip_mix_suffix(title)
ntb = norm(tb)
if (length(ntb) < 4) next
k = na sep ntb
paths[k] = paths[k] id sep path "\n"
count[k]++
raws3[k][na sep norm(album) sep norm(title)] = 1
albums[k][album] = 1
}
END {
for (k in paths) {
if (count[k] < 2) continue
# Skip if all items share the same Pass 3 normalized key (Pass 3 already handled).
rcount = 0; for (r in raws3[k]) rcount++
if (rcount < 2) continue
# Require ≥2 distinct album names — otherwise Pass 3 would have grouped them.
acount = 0; for (a in albums[k]) acount++
if (acount < 2) continue
print "===" k "\n" paths[k] "---"
}
}' /tmp/all-tracks.txt > /tmp/dups-grouped.txt
GROUP_KEY=""
GROUP_PATHS=()
flush_p4_group() {
[[ ${#GROUP_PATHS[@]} -lt 2 ]] && { GROUP_PATHS=(); return; }
process_group "P4 cross-album: $GROUP_KEY" "${GROUP_PATHS[@]}"
GROUP_PATHS=()
}
while IFS= read -r line; do
if [[ "$line" == ===* ]]; then
flush_p4_group
GROUP_KEY="${line#===}"
GROUP_PATHS=()
elif [[ "$line" == "---" ]]; then
flush_p4_group
elif [[ -n "$line" ]]; then
GROUP_PATHS+=("$line")
fi
done < /tmp/dups-grouped.txt
flush_p4_group # flush last group
P4_GROUPS=$(grep -c '^--- P4 ' "$LOG" 2>/dev/null; true)
log ""
log "Pass 4 complete: $P4_GROUPS cross-album dup groups"
rm -f /tmp/beets-all-paths.txt /tmp/beets-id-paths.txt /tmp/all-tracks.txt /tmp/dups-grouped.txt
TOTAL_GROUPS=$(( P1_GROUPS + P2_GROUPS + P3_GROUPS + P4_GROUPS ))
KEPT=$(grep -c '^ KEEP ' "$LOG" 2>/dev/null; true)
DELETED=$(grep -c '^ DELETE ' "$LOG" 2>/dev/null; true)
log ""
log "=== Summary: $TOTAL_GROUPS dup groups ($P1_GROUPS numbered-sibling, $P2_GROUPS case-insensitive, $P3_GROUPS normalized, $P4_GROUPS cross-album), $KEPT kept, $DELETED to delete ==="
[[ $APPLY -eq 0 ]] && log "DRY RUN — re-run with --apply to actually delete"
+510
View File
@@ -0,0 +1,510 @@
#!/usr/bin/env python3
"""
enrich-buy-url.py — best-effort buy links for non-purchase (Soulseek) tracks.
For every library FLAC that has no buy link yet, look the track up by
artist + title and, on an EXACT match, stamp the store page into the
COMMERCIAL_INFORMATION tag (same field the Bandcamp-purchase pipeline uses,
which AzuraCast maps to the buy_url custom field — see README "Buy links").
Source cascade (configurable), ordered by quality / DRM-freedom / artist
payout: Bandcamp first (lossless, best payout), then Qobuz (DRM-free hi-res),
then the iTunes Search API as the last-resort fallback. EXACT matching only —
exact artist (primary artist; collabs allowed as a leading match) AND exact
title — to keep wrong links out of the files, per the err-toward-false-negatives
policy.
Qobuz needs a logged-in session: it reads a user_auth_token (from --qobuz-token,
$QOBUZ_AUTH_TOKEN, or $ALEMBIC_CONFIG_DIR/pipeline/qobuz/token — grab it from the Web Player's
X-User-Auth-Token request header) and an app_id (auto-scraped from the Web Player
bundle, or --qobuz-app-id / $QOBUZ_APP_ID / $ALEMBIC_CONFIG_DIR/pipeline/qobuz/app_id). With no
token, Qobuz is silently dropped from the cascade and the run falls through to
the next source.
Resumable: files that already carry a buy link are skipped, so a re-run only
processes new/untagged tracks. Safe to wire into cron after the nightly
playlist runs.
Stdlib only. Dry-run by default.
Usage:
enrich-buy-url.py [--library DIR] [--apply] [--force]
[--sources bandcamp,qobuz,itunes] [--limit N]
[--bc-sleep S] [--qb-sleep S] [--it-sleep S]
[--qobuz-token TOKEN] [--qobuz-app-id ID]
[--azuracast-key ID:SECRET] [--azuracast-base URL] [--station N]
"""
import sys, os, re, json, time, argparse, subprocess
import urllib.request, urllib.parse
from pathlib import Path
UA = "Mozilla/5.0 (X11; Linux x86_64) enrich-buy-url/1.0"
BUY_URL_TAG = "COMMERCIAL_INFORMATION"
ITUNES_SEARCH = "https://itunes.apple.com/search"
BC_AUTOCOMPLETE = "https://bandcamp.com/api/bcsearch_public_api/1/autocomplete_elastic"
QOBUZ_SEARCH = "https://www.qobuz.com/api.json/0.2/track/search"
QOBUZ_ALBUM_GET = "https://www.qobuz.com/api.json/0.2/album/get"
QOBUZ_LOGIN_PAGE = "https://play.qobuz.com/login"
_ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
QOBUZ_TOKEN_FILE = f"{_ALEMBIC_CONFIG_DIR}/pipeline/qobuz/token"
QOBUZ_APPID_FILE = f"{_ALEMBIC_CONFIG_DIR}/pipeline/qobuz/app_id"
QOBUZ_REGION_FILE = f"{_ALEMBIC_CONFIG_DIR}/pipeline/qobuz/region"
QOBUZ_DEFAULT_REGION = "ca-en"
# Populated once by resolve_qobuz() during startup; None disables the source.
# Carries {token, app_id, region}.
QOBUZ_CREDS = None
# ---- matching --------------------------------------------------------------
def norm(s):
s = (s or "").lower()
s = re.sub(r"\((feat|ft|featuring|with)\.?[^)]*\)", "", s)
s = re.sub(r"\b(feat|ft|featuring)\.?\s.*$", "", s)
s = re.sub(r"[^a-z0-9]+", " ", s)
return re.sub(r"\s+", " ", s).strip()
def artist_ok(query, result):
"""Exact, or the queried (primary) artist leads a collab credit."""
nq, nr = norm(query), norm(result)
if not nq or not nr:
return False
return nr == nq or nr.startswith(nq + " ") or nq.startswith(nr + " ")
def title_ok(query, result):
return norm(query) == norm(result) and norm(query) != ""
def url_source(url):
"""Best-effort: which store an already-stored buy link came from. Used by
--upgrade-from to spot links worth re-evaluating against higher-priority
sources. Unknown/white-label domains are treated as Bandcamp (its custom
domains, e.g. music.monstercat.com, are the only ones we can't fingerprint)."""
u = (url or "").lower()
if "apple.com" in u or "itunes" in u:
return "itunes"
if "qobuz.com" in u:
return "qobuz"
return "bandcamp"
# ---- sources ---------------------------------------------------------------
def _open(req, timeout):
"""One retry with backoff on throttle/temporary errors (polite + better yield)."""
for attempt in range(2):
try:
return urllib.request.urlopen(req, timeout=timeout).read()
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503) and attempt == 0:
time.sleep(5)
continue
raise
def _get(url, timeout=30):
return _open(urllib.request.Request(url, headers={"User-Agent": UA}), timeout)
def _post(url, body, timeout=30):
req = urllib.request.Request(
url, data=json.dumps(body).encode(),
headers={"User-Agent": UA, "Content-Type": "application/json"})
return _open(req, timeout)
def lookup_bandcamp(artist, title):
"""Return a track-page URL on an exact match, else None."""
try:
raw = _post(BC_AUTOCOMPLETE, {
"search_text": f"{artist} {title}",
"search_filter": "", "full_page": False, "fan_id": None})
results = (json.loads(raw).get("auto") or {}).get("results", [])
except Exception:
return None
for r in results:
if r.get("type") != "t": # tracks only (exact title)
continue
if artist_ok(artist, r.get("band_name", "")) and title_ok(title, r.get("name", "")):
return r.get("item_url_path") or r.get("url")
return None
def lookup_itunes(artist, title):
"""Return a store URL on an exact match (prefer buyable), else None."""
try:
url = ITUNES_SEARCH + "?" + urllib.parse.urlencode(
{"term": f"{artist} {title}", "entity": "song", "limit": 8})
results = json.loads(_get(url)).get("results", [])
except Exception:
return None
fallback = None
for r in results:
if artist_ok(artist, r.get("artistName", "")) and title_ok(title, r.get("trackName", "")):
link = r.get("trackViewUrl")
if not link:
continue
if r.get("trackPrice"): # buyable wins
return link
fallback = fallback or link
return fallback
def scrape_qobuz_app_id(log):
"""Pull the production app_id out of the Web Player bundle. Best-effort:
returns None if the page/bundle layout changed."""
try:
page = _get(QOBUZ_LOGIN_PAGE, timeout=30).decode("utf-8", "replace")
m = re.search(r"/resources/[\w.\-]+/bundle\.js", page)
if not m:
log(" [qobuz] could not locate bundle.js on the login page")
return None
bundle = _get("https://play.qobuz.com" + m.group(0), timeout=90).decode("utf-8", "replace")
m = re.search(r'production:\{api:\{appId:"(\d+)"', bundle)
if not m:
log(" [qobuz] could not find appId in bundle.js")
return None
return m.group(1)
except Exception as e:
log(f" [qobuz] app_id scrape failed: {e}")
return None
def resolve_qobuz(args, log):
"""Resolve {token, app_id, region} for the Qobuz source, or None to disable
it. Token: --qobuz-token, $QOBUZ_AUTH_TOKEN, then the keyfile. app_id: flag/
env/keyfile, else auto-scraped from the Web Player bundle. region: flag/env/
keyfile, else the default (controls which store the buy link points at)."""
token = args.qobuz_token or os.environ.get("QOBUZ_AUTH_TOKEN")
if not token and os.path.exists(QOBUZ_TOKEN_FILE):
token = Path(QOBUZ_TOKEN_FILE).read_text().strip()
if not token:
log(" [qobuz] no user_auth_token (flag/env/keyfile) — dropping Qobuz from cascade")
return None
app_id = args.qobuz_app_id or os.environ.get("QOBUZ_APP_ID")
if not app_id and os.path.exists(QOBUZ_APPID_FILE):
app_id = Path(QOBUZ_APPID_FILE).read_text().strip()
if not app_id:
app_id = scrape_qobuz_app_id(log)
if not app_id:
log(" [qobuz] no app_id (flag/env/keyfile/scrape) — dropping Qobuz from cascade")
return None
region = getattr(args, "qobuz_region", None) or os.environ.get("QOBUZ_REGION")
if not region and os.path.exists(QOBUZ_REGION_FILE):
region = Path(QOBUZ_REGION_FILE).read_text().strip()
region = (region or QOBUZ_DEFAULT_REGION).strip().strip("/")
return {"token": token.strip(), "app_id": str(app_id).strip(), "region": region}
def qobuz_store_url(album_id):
"""Resolve an album id to its canonical, regioned *purchase* page
(www.qobuz.com/<region>/album/<slug>/<id>) via album/get. Returns None if the
album isn't purchasable or can't be fetched — so the caller falls through to
the next source rather than stamping a streaming-only link. We deliberately
do NOT use open.qobuz.com (that's the player/streaming deep link)."""
if not (QOBUZ_CREDS and album_id):
return None
try:
url = QOBUZ_ALBUM_GET + "?" + urllib.parse.urlencode(
{"album_id": album_id, "app_id": QOBUZ_CREDS["app_id"]})
req = urllib.request.Request(url, headers={
"User-Agent": UA,
"X-App-Id": QOBUZ_CREDS["app_id"],
"X-User-Auth-Token": QOBUZ_CREDS["token"]})
d = json.loads(_open(req, 30))
except Exception:
return None
# album/get returns a region-less relative_url like /album/<slug>/<id>; the
# bare `url` is locale-pinned to the account default (fr-fr), so we build from
# relative_url + the configured region instead.
rel = d.get("relative_url")
if not d.get("purchasable") or not rel:
return None
return f"https://www.qobuz.com/{QOBUZ_CREDS['region']}{rel}"
def lookup_qobuz(artist, title):
"""Return a Qobuz *purchase* URL on an exact, purchasable match, else None
(falls through to the next source). Requires QOBUZ_CREDS."""
if not QOBUZ_CREDS:
return None
try:
url = QOBUZ_SEARCH + "?" + urllib.parse.urlencode(
{"query": f"{artist} {title}", "limit": 10, "app_id": QOBUZ_CREDS["app_id"]})
req = urllib.request.Request(url, headers={
"User-Agent": UA,
"X-App-Id": QOBUZ_CREDS["app_id"],
"X-User-Auth-Token": QOBUZ_CREDS["token"]})
items = (json.loads(_open(req, 30)).get("tracks") or {}).get("items", [])
except Exception:
return None
tried = 0
for t in items:
album = t.get("album") or {}
performer = (t.get("performer") or {}).get("name", "") or \
(album.get("artist") or {}).get("name", "")
if not (artist_ok(artist, performer) and title_ok(title, t.get("title", ""))):
continue
# Search carries only the album id; album/get yields the purchase page and
# gates on purchasability. A given track can appear on several releases
# (single, album, comp) — try the first few for a purchasable one before
# giving up and falling through to iTunes.
time.sleep(0.3) # polite: a second call per match
link = qobuz_store_url(album.get("id"))
if link:
return link
tried += 1
if tried >= 3:
break
return None
SOURCES = {"bandcamp": lookup_bandcamp, "qobuz": lookup_qobuz, "itunes": lookup_itunes}
# ---- tag I/O ---------------------------------------------------------------
def flac_tag(path, name):
r = subprocess.run(["metaflac", f"--show-tag={name}", path], capture_output=True, text=True)
for line in r.stdout.splitlines():
if "=" in line:
return line.split("=", 1)[1]
return ""
def set_flac_tag(path, name, value):
subprocess.run(["metaflac", f"--remove-tag={name}", path], capture_output=True)
return subprocess.run(["metaflac", f"--set-tag={name}={value}", path],
capture_output=True).returncode == 0
# ---- AzuraCast reprocess (same as backfill-buy-url.py) ----------------------
def az_reprocess(base, key, station, host_paths, library, log):
root = library.rstrip("/") + "/"
want = {p[len(root):] for p in host_paths if p.startswith(root)}
headers = {"X-API-Key": key, "Accept": "application/json"}
def get(p):
return json.loads(urllib.request.urlopen(
urllib.request.Request(base + p, headers=headers), timeout=90).read())
uids, page = [], 1
while True:
d = get(f"/api/station/{station}/files?per_page=500&page={page}")
for row in d["rows"]:
if row["path"] in want:
uids.append(row["unique_id"])
if page >= d["total_pages"]:
break
page += 1
if not uids:
log(" no matching AzuraCast files to reprocess")
return
# AzuraCast caps batch size in practice; chunk to be safe.
for i in range(0, len(uids), 200):
chunk = uids[i:i + 200]
req = urllib.request.Request(
base + f"/api/station/{station}/files/batch",
data=json.dumps({"do": "reprocess", "files": chunk}).encode(), method="PUT",
headers={**headers, "Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=120).read()
log(f" queued reprocess for {len(uids)} AzuraCast file(s)")
def refresh_qobuz_links(flacs, args, az_key):
"""Rewrite existing open.qobuz.com (streaming) buy links to the canonical
www.qobuz.com purchase page, converting by album id (no re-matching). Links
that aren't purchasable / can't be resolved are left intact and counted."""
print(f"[refresh-qobuz] mode={'APPLY' if args.apply else 'DRY RUN'} "
f"region={QOBUZ_CREDS['region']}")
print(f"[refresh-qobuz] scanning {len(flacs)} FLAC files...\n")
looked = converted = unconvertible = 0
touched = []
for p in flacs:
sp = str(p)
existing = flac_tag(sp, BUY_URL_TAG)
if not existing or "open.qobuz.com" not in existing:
continue
m = re.search(r"open\.qobuz\.com/album/([^/?#]+)", existing)
if not m:
continue # track-form deep link: leave it
if args.limit and looked >= args.limit:
break
looked += 1
time.sleep(args.qb_sleep)
new = qobuz_store_url(m.group(1))
rel = sp[len(args.library):].lstrip("/")
if not new or new == existing:
unconvertible += 1 # not purchasable in-region / gone
continue
converted += 1
print(f" ~ {rel}\n {existing}\n -> {new}")
if args.apply:
if set_flac_tag(sp, BUY_URL_TAG, new):
touched.append(sp)
else:
print(" ! metaflac write failed")
else:
touched.append(sp)
print(f"\n[refresh-qobuz] {looked} open.qobuz links, "
f"{converted} {'converted' if args.apply else 'would-convert'}, "
f"{unconvertible} left intact (not purchasable / unresolved)")
if args.apply and touched and az_key:
print("[refresh-qobuz] telling AzuraCast to reprocess touched files...")
az_reprocess(args.azuracast_base, az_key, args.station,
touched, args.library, lambda m: print(m))
elif not args.apply:
print("[refresh-qobuz] re-run with --apply to write")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--library", default=f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library")
ap.add_argument("--apply", action="store_true")
ap.add_argument("--force", action="store_true", help="re-match files that already have a buy link")
ap.add_argument("--upgrade-from", default="",
help="comma-separated sources whose existing links should be "
"re-evaluated and REPLACED if a higher-priority source now "
"matches (e.g. 'itunes'). Lower-priority matches never "
"overwrite; a no-match leaves the existing link intact.")
ap.add_argument("--sources", default="bandcamp,qobuz,itunes",
help="comma-separated cascade order (subset of: bandcamp, qobuz, itunes)")
ap.add_argument("--limit", type=int, default=0, help="stop after N lookups (0 = all)")
ap.add_argument("--bc-sleep", type=float, default=0.7)
ap.add_argument("--qb-sleep", type=float, default=0.8)
ap.add_argument("--it-sleep", type=float, default=1.0)
ap.add_argument("--qobuz-token", default=None,
help="Qobuz user_auth_token (else $QOBUZ_AUTH_TOKEN or "
f"{QOBUZ_TOKEN_FILE})")
ap.add_argument("--qobuz-app-id", default=None,
help="Qobuz app_id (else $QOBUZ_APP_ID, keyfile, or auto-scrape)")
ap.add_argument("--qobuz-region", default=None,
help=f"Qobuz store region for buy links, e.g. ca-en (else "
f"$QOBUZ_REGION, {QOBUZ_REGION_FILE}, or {QOBUZ_DEFAULT_REGION})")
ap.add_argument("--refresh-qobuz", action="store_true",
help="one-shot: rewrite existing open.qobuz.com (streaming) buy "
"links to the canonical www.qobuz.com purchase page. No "
"re-matching — converts by album id. Skips links that "
"aren't purchasable (leaves them intact).")
ap.add_argument("--azuracast-key", default=None)
ap.add_argument("--azuracast-base", default="https://admin.ephemeral.club")
ap.add_argument("--station", type=int, default=1)
args = ap.parse_args()
# Resolve the AzuraCast key (for the reprocess trigger) without putting the
# secret on the command line / in cron: --azuracast-key, then env, then file.
az_key = args.azuracast_key or os.environ.get("AZURACAST_API_KEY")
if not az_key:
keyfile = f"{_ALEMBIC_CONFIG_DIR}/pipeline/azuracast/api_key"
if os.path.exists(keyfile):
az_key = Path(keyfile).read_text().strip()
cascade = [s.strip() for s in args.sources.split(",") if s.strip() in SOURCES]
if not cascade:
sys.exit(f"[enrich] no valid sources in {args.sources!r}")
sleep_for = {"bandcamp": args.bc_sleep, "qobuz": args.qb_sleep, "itunes": args.it_sleep}
# Qobuz needs auth; resolve it now and drop the source if unavailable so the
# cascade just falls through (err-toward-false-negatives — no guessed links).
# --refresh-qobuz also needs the creds (to call album/get).
global QOBUZ_CREDS
if "qobuz" in cascade or args.refresh_qobuz:
QOBUZ_CREDS = resolve_qobuz(args, lambda m: print(m))
if not QOBUZ_CREDS:
cascade = [s for s in cascade if s != "qobuz"]
if args.refresh_qobuz:
sys.exit("[enrich] --refresh-qobuz needs Qobuz creds — aborting")
upgrade_from = {s.strip() for s in args.upgrade_from.split(",")
if s.strip() in SOURCES}
flacs = list(Path(args.library).rglob("*.flac"))
# --- one-shot migration: open.qobuz.com (streaming) -> purchase page -------
if args.refresh_qobuz:
refresh_qobuz_links(flacs, args, az_key)
return
print(f"[enrich] mode={'APPLY' if args.apply else 'DRY RUN'} cascade={cascade}"
+ (f" upgrade-from={sorted(upgrade_from)}" if upgrade_from else ""))
print(f"[enrich] {len(flacs)} FLAC files in library\n")
looked = found = upgraded = 0
by_source = {s: 0 for s in cascade}
touched = []
for p in flacs:
sp = str(p)
existing = flac_tag(sp, BUY_URL_TAG)
# Decide which sources to actually query for this file.
if existing and not args.force:
cur = url_source(existing)
if upgrade_from and cur in upgrade_from:
# Only try sources ranked ABOVE the current one — a same/lower
# match must never replace it. No-match leaves the link intact.
run_sources = cascade[:cascade.index(cur)] if cur in cascade else list(cascade)
if not run_sources:
continue
else:
continue # already tagged, not up for upgrade
else:
run_sources = cascade # untagged, or --force
artist = flac_tag(sp, "ARTIST").split(";")[0].strip() or flac_tag(sp, "ALBUMARTIST")
title = flac_tag(sp, "TITLE")
if not (artist and title):
continue
if args.limit and looked >= args.limit:
break
looked += 1
url = src = None
for s in run_sources:
url = SOURCES[s](artist, title)
time.sleep(sleep_for[s])
if url:
src = s
break
rel = sp[len(args.library):].lstrip("/")
if not url:
continue
found += 1
by_source[src] += 1
if existing:
upgraded += 1
print(f" ~ [{src}] {artist} - {title}\n -> {url}\n (replaces {existing})")
else:
print(f" + [{src}] {artist} - {title}\n -> {url}")
if args.apply:
if set_flac_tag(sp, BUY_URL_TAG, url):
touched.append(sp)
else:
print(" ! metaflac write failed")
else:
touched.append(sp)
if found % 50 == 0:
print(f" ...{found} links from {looked} lookups so far", flush=True)
print(f"\n[enrich] {looked} lookups, {found} {'tagged' if args.apply else 'would-tag'}"
f" ({', '.join(f'{s}={by_source[s]}' for s in cascade)})"
f" no-match={looked - found}"
+ (f" upgraded={upgraded}" if upgrade_from else ""))
if args.apply and touched and az_key:
print("[enrich] telling AzuraCast to reprocess touched files...")
az_reprocess(args.azuracast_base, az_key, args.station,
touched, args.library, lambda m: print(m))
elif args.apply and touched and not az_key:
print("[enrich] no AzuraCast key (flag/env/keyfile) — tags written; "
"AzuraCast's periodic media scan will pick them up.")
elif not args.apply:
print("[enrich] re-run with --apply to write (key via --azuracast-key, "
"$AZURACAST_API_KEY, or $ALEMBIC_CONFIG_DIR/pipeline/azuracast/api_key to reprocess)")
if __name__ == "__main__":
main()
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
export-laptop-playlists.py — daily Navidrome → Traktor M3U export.
Lists every Navidrome playlist whose name starts with `dj-`, fetches each
playlist's tracks via Subsonic, looks up the real on-disk path of each
track in beets, and writes a `.m3u` for Syncthing → Traktor.
Output dir: $MUSIC_DATA_DIR/playlists-laptop/
- One `dj-<name>.m3u` per Navidrome dj-* playlist (overwritten each run)
- Exact format Traktor Pro 4 itself uses when you Export Playlist as M3U:
one absolute Windows path per line (`C:\\Music\\Library\\...\\Track.flac`),
UTF-8 encoded, LF line endings, NO `#EXTM3U` header. Traktor's M3U
importer treats `#EXTM3U` as either a parse error or a phantom track
path, which kicks the rest of the import into "treat-as-new" mode and
the playlist ends up with unanalyzed records. With the header omitted,
Traktor parses each line as a LOCATION, matches against the existing
Collection by DIR+FILE+VOLUME, and the playlist inherits BPM, waveform,
and key from the already-analyzed entries.
Why beets, not Subsonic, for paths: Navidrome's Subsonic `path` attribute
is a *synthesized* string like `Artist/Album/01 - Title.flac` even when
the real file is just `Artist/Album/Title.flac`. The track-number prefix
is generated from metadata, not read from disk. Traktor then can't find
the file. Beets stores the real path, so we look up each track by
(album, title) in a one-shot beets index, falling back to artist
disambiguation when multiple albums share the same album+title.
Reconciles deletes: any `dj-*.m3u` on disk whose stem is no longer a
current Navidrome playlist is removed; also sweeps legacy `.m3u8`/`.nml`
files from earlier experiments.
Existing M3U files in `$MUSIC_DATA_DIR/playlists/` (Navidrome's M3U
import source for the automated Spotify pipeline) are NOT touched.
Usage:
export-laptop-playlists.py [--verbose]
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
_ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
_MUSIC_DATA_DIR = os.environ.get("MUSIC_DATA_DIR", "/data/music")
OUT_DIR = f"{_MUSIC_DATA_DIR}/playlists-laptop"
# Absolute Windows path to the laptop's library root.
LIBRARY_LAPTOP_ROOT = r"C:\Music\Library"
# Beets stores paths with this prefix (the container-side mount of the
# library); strip it to get the path relative to the library root. This is
# still "/music/" through the migration's Stage 0-3 transitional mount;
# revisit at Stage 4 once beets' directory: becomes MUSIC_DATA_DIR/Library.
BEETS_LIBRARY_PREFIX = "/music/"
M3U_EXT = ".m3u"
# Older formats from earlier iterations of this script — swept by reconcile.
LEGACY_EXTS = (".m3u8", ".nml")
def _load_navidrome_env():
path = f"{_ALEMBIC_CONFIG_DIR}/pipeline/navidrome/admin.env"
env = {}
if os.path.exists(path):
for line in open(path):
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v.strip().strip("'").strip('"')
return env
_nd_env = _load_navidrome_env()
ND_BASE = _nd_env.get("ND_BASE", "http://navidrome:4533")
ND_USER = _nd_env.get("ND_USER", "andrew")
ND_PASS = _nd_env.get("ND_PASS", "")
ND_API_VERSION = "1.16.0"
ND_CLIENT = "dj-export"
NS = "{http://subsonic.org/restapi}"
# ASCII Unit Separator — safe field separator for beets `-f` output.
SEP = "\x1f"
def _call(endpoint: str, params: dict[str, str]) -> ET.Element:
qp = {
"u": ND_USER, "p": ND_PASS,
"v": ND_API_VERSION, "c": ND_CLIENT, "f": "xml",
**params,
}
url = f"{ND_BASE}/rest/{endpoint}.view?{urllib.parse.urlencode(qp)}"
with urllib.request.urlopen(url, timeout=60) as r:
body = r.read()
root = ET.fromstring(body)
if root.attrib.get("status") != "ok":
err = root.find(f"{NS}error")
msg = err.attrib.get("message", "unknown") if err is not None else "?"
raise RuntimeError(f"Subsonic {endpoint} failed: {msg}")
return root
def list_dj_playlists() -> list[tuple[str, str]]:
root = _call("getPlaylists", {})
out: list[tuple[str, str]] = []
for p in root.iter(f"{NS}playlist"):
name = p.attrib.get("name", "")
if name.startswith("dj-"):
out.append((p.attrib["id"], name))
return out
def get_playlist_tracks(pid: str) -> list[tuple[str, str, str]]:
"""Return (albumartist, album, title) tuples per playlist entry."""
root = _call("getPlaylist", {"id": pid})
out: list[tuple[str, str, str]] = []
for entry in root.iter(f"{NS}entry"):
aa = (entry.attrib.get("displayAlbumArtist")
or entry.attrib.get("artist", ""))
album = entry.attrib.get("album", "")
title = entry.attrib.get("title", "")
out.append((aa, album, title))
return out
def _normkey(s: str) -> str:
"""Lowercase + normalize curly quotes to straight ASCII."""
return (s.lower()
.replace("", "'").replace("", "'")
.replace("", '"').replace("", '"'))
def build_beets_index() -> dict[tuple[str, str], list[tuple[str, str, str, str]]]:
"""Map (album, title) →
[(albumartist, album, title, library-relative path), ...].
Album and title are returned with original casing (not _normkey'd) so the
NML COLLECTION entries can carry the real metadata for display."""
fmt = SEP.join(["$albumartist", "$album", "$title", "$path"])
res = subprocess.run(
["beet", "ls", "-f", fmt],
capture_output=True, text=True, check=True
)
idx: dict[tuple[str, str], list[tuple[str, str, str, str]]] = {}
for ln in res.stdout.splitlines():
parts = ln.split(SEP)
if len(parts) != 4:
continue
aa, alb, ti, path = parts
if not path.startswith(BEETS_LIBRARY_PREFIX):
continue
rel = path[len(BEETS_LIBRARY_PREFIX):]
idx.setdefault((_normkey(alb), _normkey(ti)), []).append((aa, alb, ti, rel))
return idx
def resolve(idx: dict[tuple[str, str], list[tuple[str, str, str, str]]],
aa_query: str, album: str, title: str
) -> tuple[str, str, str, str] | None:
"""Return (albumartist, album, title, rel_path) for a Subsonic track, or None."""
candidates = idx.get((_normkey(album), _normkey(title)))
if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
q = _normkey(aa_query).replace("", " ").replace(";", " ")
for c in candidates:
if _normkey(c[0]) in q:
return c
return candidates[0]
def to_windows_path(rel: str) -> str:
"""Library-relative POSIX path → absolute Windows path with backslashes."""
return LIBRARY_LAPTOP_ROOT + "\\" + rel.replace("/", "\\")
def write_m3u(name: str, tracks: list[tuple[str, str, str, str]], verbose: bool) -> int:
"""Plain-path M3U, no `#EXTM3U` header — matches the format Traktor
Pro 4 itself emits via Export Playlist → M3U. With this byte-exact
format, Traktor's import re-uses existing analyzed Collection entries
(matched by DIR+FILE+VOLUME) instead of creating fresh unanalyzed
records."""
target = os.path.join(OUT_DIR, f"{name}{M3U_EXT}")
tmp = target + ".tmp"
with open(tmp, "w", encoding="utf-8", newline="\n") as f:
for _aa, _alb, _ti, rel in tracks:
f.write(to_windows_path(rel) + "\n")
os.replace(tmp, target)
if verbose:
print(f" wrote {target} ({len(tracks)} tracks)")
return len(tracks)
def reconcile(current_names: set[str], verbose: bool) -> int:
"""Delete dj-*.m3u files whose stem is no longer current, and sweep
any legacy .m3u8/.nml leftovers from earlier iterations."""
removed = 0
if not os.path.isdir(OUT_DIR):
return 0
for entry in os.scandir(OUT_DIR):
if not entry.is_file() or not entry.name.startswith("dj-"):
continue
if entry.name.endswith(M3U_EXT):
stem = entry.name[:-len(M3U_EXT)]
if stem in current_names:
continue
elif any(entry.name.endswith(e) for e in LEGACY_EXTS):
pass # always sweep
else:
continue
os.remove(entry.path)
removed += 1
if verbose:
print(f" removed stale {entry.path}")
return removed
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
print(f"[{stamp}] export-laptop-playlists starting")
idx = build_beets_index()
print(f" beets index: {len(idx)} (album,title) keys")
playlists = list_dj_playlists()
print(f" found {len(playlists)} dj-* playlists in Navidrome")
# Safety guard: if Navidrome answers successfully but reports ZERO dj-*
# playlists while exported files exist on disk, something is wrong on the
# Navidrome side (fresh/restored DB, purge, renamed playlists). Running
# reconcile() would delete every dj-*.m3u and Syncthing would propagate
# the deletions to the laptop — the 2026-06-01 Traktor wipe through the
# other door. Refuse and keep the files.
if not playlists:
existing = [e.name for e in os.scandir(OUT_DIR)
if e.is_file() and e.name.startswith("dj-")]
if existing:
print(f" WARN: Navidrome returned 0 dj-* playlists but "
f"{len(existing)} dj-* file(s) exist in {OUT_DIR}"
f"REFUSING to reconcile/delete. Check Navidrome before "
f"trusting this.")
return 0
total_tracks = 0
total_unresolved = 0
written = 0
skipped = 0
for pid, name in playlists:
try:
tracks = get_playlist_tracks(pid)
except Exception as e:
print(f" WARN: failed to fetch {name}: {e}")
continue
resolved: list[tuple[str, str, str, str]] = []
unresolved: list[tuple[str, str, str]] = []
for aa, album, title in tracks:
hit = resolve(idx, aa, album, title)
if hit is None:
unresolved.append((aa, album, title))
continue
resolved.append(hit)
if unresolved:
total_unresolved += len(unresolved)
print(f" WARN: {name}: {len(unresolved)} track(s) not in beets index "
f"(skipped). First: {unresolved[0]}")
# Safety guard: never blow away a populated .m3u with an empty export.
# On 2026-06-01 a PURGEMISSING scan emptied the Navidrome dj-* playlists
# while the QNAP share was flaky; this script then overwrote every
# laptop .m3u with 0 tracks, and the laptop's auto-merge wiped Traktor.
# If Navidrome resolves to 0 tracks but a non-empty file already exists,
# keep the file and warn loudly instead of trusting the empty result.
if not resolved:
target = os.path.join(OUT_DIR, f"{name}{M3U_EXT}")
if os.path.exists(target) and os.path.getsize(target) > 0:
print(f" WARN: {name}: Navidrome returned 0 resolvable tracks but "
f"{target} is non-empty — REFUSING to overwrite (kept existing). "
f"Check Navidrome (likely a purge/scan glitch) before trusting this.")
skipped += 1
continue
total_tracks += write_m3u(name, resolved, args.verbose)
written += 1
current_names = {name for _pid, name in playlists}
removed = reconcile(current_names, args.verbose)
print(f"[{stamp}] done. wrote {written} playlists, {total_tracks} total tracks, "
f"{total_unresolved} unresolved, {skipped} kept (empty-guard), "
f"removed {removed} stale files")
return 0
if __name__ == "__main__":
sys.exit(main())
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""
find-fuzzy-dupes.py — find acoustic duplicates in the beets library using
the existing $ALEMBIC_CONFIG_DIR/beets/fingerprints.db (Chromaprint).
Pairs whose duration differs by <=5s and Chromaprint similarity >= 0.92 are
flagged as duplicates. Groups are transitively merged. Within each group the
ranking is: FLAC > MP3/other, then largest size wins.
DRY RUN by default. Pass --apply to delete losers via `beet remove -d -f`.
Usage:
find-fuzzy-dupes.py
find-fuzzy-dupes.py --apply
find-fuzzy-dupes.py --threshold 0.95 --apply
"""
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
from collections import defaultdict
import numpy as np
FINGERPRINT_DB = f"{os.environ.get('ALEMBIC_CONFIG_DIR', '/config')}/beets/fingerprints.db"
SCAN_CACHE = "/tmp/find-fuzzy-dupes-scan-cache.json"
DEFAULT_THRESHOLD = 0.92
DURATION_TOLERANCE = 5 # seconds
# Library dedup compares same-track recordings — offsets are tiny. Use a
# smaller window than DJ-import (±80) so a 4k-file library scans in minutes.
MAX_OFFSET = 15
# Precomputed bitmasks for popcount (reused across compares).
_M1 = np.uint32(0x55555555)
_M2 = np.uint32(0x33333333)
_M4 = np.uint32(0x0F0F0F0F)
_H01 = np.uint32(0x01010101)
def _sim_at_offset(fp1: np.ndarray, fp2: np.ndarray, offset: int) -> float:
if offset >= 0:
n = min(fp1.size - offset, fp2.size)
if n < 100:
return 0.0
xor = np.bitwise_xor(fp1[offset : offset + n], fp2[:n])
else:
n = min(fp1.size, fp2.size + offset)
if n < 100:
return 0.0
xor = np.bitwise_xor(fp1[:n], fp2[-offset : -offset + n])
v = xor - ((xor >> 1) & _M1)
v = (v & _M2) + ((v >> 2) & _M2)
v = (v + (v >> 4)) & _M4
diff_bits = int(((v * _H01) >> 24).sum())
return 1.0 - diff_bits / (n * 32.0)
def _compare_fingerprints(fp1: np.ndarray, fp2: np.ndarray, max_offset: int = MAX_OFFSET) -> float:
if fp1.size < 50 or fp2.size < 50:
return 0.0
# Fast pre-filter: try offset 0 first. If unrelated, skip the offset
# search entirely. Same recordings cluster within a few offsets of 0,
# so a sub-0.50 base score is a definitive "not a duplicate."
base = _sim_at_offset(fp1, fp2, 0)
if base < 0.50:
return base
best = base
if best >= 0.99:
return best
for offset in range(1, max_offset + 1):
for o in (offset, -offset):
sim = _sim_at_offset(fp1, fp2, o)
if sim > best:
best = sim
if best >= 0.99:
return best
return best
_DESKTOP_RE = re.compile(r"-DESKTOP-[A-Z0-9]+", re.IGNORECASE)
_DUP_SUFFIX_RE = re.compile(r"\(\d+\)\.[a-z]+$|\.\d+\.[a-z]+$", re.IGNORECASE)
# DJ/club-friendly versions to prefer when same audio matches at different
# labellings. Bonus is small enough not to override format/size when those
# signal a real quality difference.
_EXTENDED_RE = re.compile(
r"\b(extended|club|dj edit|dj mix|long version|original mix)\b",
re.IGNORECASE,
)
def rank_file(host_path: str) -> int:
"""Lower is better. FLAC > non-FLAC; clean filename > sync-conflict
artifact; extended/club mixes beat radio edits at same format; then
larger wins. Penalty weights:
DESKTOP-suffix (OneDrive sync conflict) > (N)/.N. (Windows/beets dup)
so an exclusively-(1)-marked file beats one that's both (1) and DESKTOP."""
try:
size = os.path.getsize(host_path)
except OSError:
size = 0
ext_score = 1 if host_path.lower().endswith(".flac") else 0
desktop = 1 if _DESKTOP_RE.search(host_path) else 0
dup = 1 if _DUP_SUFFIX_RE.search(host_path) else 0
extended = 1 if _EXTENDED_RE.search(host_path) else 0
return (
10_000_000_000
- ext_score * 1_000_000_000_000
+ desktop * 500_000_000_000
+ dup * 100_000_000_000
- extended * 50_000_000_000
- size
)
class UnionFind:
def __init__(self, ids):
self.parent = {i: i for i in ids}
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[ra] = rb
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--apply", action="store_true", help="actually delete losers")
ap.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD)
ap.add_argument("--duration-tol", type=int, default=DURATION_TOLERANCE)
ap.add_argument("--no-cache", action="store_true",
help="ignore the scan cache and re-fingerprint everything")
args = ap.parse_args()
if not os.path.exists(FINGERPRINT_DB):
print(f"ERROR: fingerprint index missing at {FINGERPRINT_DB}", file=sys.stderr)
print("Run build-fingerprint-index.py first.", file=sys.stderr)
return 2
conn = sqlite3.connect(f"file:{FINGERPRINT_DB}?mode=ro", uri=True)
rows = list(conn.execute("SELECT beets_id, path, duration, fingerprint FROM fingerprints"))
conn.close()
entries = []
for beets_id, path, duration, fp_csv in rows:
fp = np.fromstring(fp_csv, dtype=np.uint32, sep=",")
if fp.size == 0:
continue
if not os.path.exists(path):
continue
entries.append((beets_id, path, duration, fp))
print(f"[fuzzy-dupes] loaded {len(entries)} fingerprints (skipped missing/empty)")
# Bucket by duration to make pairwise scan O(N * window) instead of O(N^2).
by_dur = defaultdict(list)
for idx, (_, _, dur, _) in enumerate(entries):
by_dur[dur].append(idx)
# Try cache first. Cache key = (fingerprint mtime, threshold, duration_tol).
fp_mtime = os.path.getmtime(FINGERPRINT_DB)
cache_key = {"fp_mtime": fp_mtime, "threshold": args.threshold,
"duration_tol": args.duration_tol, "n_entries": len(entries)}
pair_scores: dict[tuple[int, int], float] = {}
if not args.no_cache and os.path.exists(SCAN_CACHE):
try:
with open(SCAN_CACHE) as f:
cache = json.load(f)
if cache.get("key") == cache_key:
pair_scores = {tuple(map(int, k.split(","))): v
for k, v in cache["pairs"].items()}
print(f"[fuzzy-dupes] loaded {len(pair_scores)} pair scores from cache "
f"({SCAN_CACHE})")
except (OSError, json.JSONDecodeError, KeyError):
pair_scores = {}
if not pair_scores:
start = time.time()
compared = 0
matched = 0
for i, (_, path_i, dur_i, fp_i) in enumerate(entries):
for d in range(dur_i - args.duration_tol, dur_i + args.duration_tol + 1):
for j in by_dur.get(d, []):
if j <= i:
continue
compared += 1
fp_j = entries[j][3]
score = _compare_fingerprints(fp_i, fp_j)
if score >= args.threshold:
pair_scores[(i, j)] = score
matched += 1
if (i + 1) % 250 == 0:
elapsed = time.time() - start
rate = (i + 1) / elapsed if elapsed > 0 else 0
print(f"[fuzzy-dupes] scanned {i + 1}/{len(entries)} "
f"({rate:.1f}/s, {compared} compared, {matched} matches)", flush=True)
try:
with open(SCAN_CACHE, "w") as f:
json.dump({
"key": cache_key,
"pairs": {f"{i},{j}": s for (i, j), s in pair_scores.items()},
}, f)
print(f"[fuzzy-dupes] cached {len(pair_scores)} pair scores → {SCAN_CACHE}")
except OSError as e:
print(f"[fuzzy-dupes] WARN: could not write cache: {e}", file=sys.stderr)
uf = UnionFind(range(len(entries)))
for (i, j) in pair_scores:
uf.union(i, j)
# Group by union-find root.
groups: dict[int, list[int]] = defaultdict(list)
for idx in range(len(entries)):
groups[uf.find(idx)].append(idx)
dup_groups = [g for g in groups.values() if len(g) >= 2]
print(f"\n[fuzzy-dupes] found {len(dup_groups)} duplicate groups "
f"covering {sum(len(g) for g in dup_groups)} files\n")
if not dup_groups:
return 0
delete_targets: list[tuple[int, str]] = []
skipped_transitive = 0
for group in sorted(dup_groups, key=lambda g: -len(g)):
ranked = sorted(group, key=lambda idx: rank_file(entries[idx][1]))
keeper_idx = ranked[0]
keeper_path = entries[keeper_idx][1]
keeper_fp = entries[keeper_idx][3]
keeper_size = os.path.getsize(keeper_path)
keeper_ext = keeper_path.rsplit(".", 1)[-1].upper()
print(f"--- group ({len(group)} files) ---")
print(f" KEEP [{keeper_ext} {keeper_size / 1024 / 1024:.1f}M] {keeper_path}")
for loser_idx in ranked[1:]:
beets_id, loser_path, _, loser_fp = entries[loser_idx]
loser_size = os.path.getsize(loser_path) if os.path.exists(loser_path) else 0
loser_ext = loser_path.rsplit(".", 1)[-1].upper()
# Verify direct similarity to the keeper. Transitive union-find can
# pull in unrelated tracks via a chain of partial matches (e.g. a
# game-OST jingle group where A~B and B~C but A and C are unrelated).
pair_key = (min(keeper_idx, loser_idx), max(keeper_idx, loser_idx))
score = pair_scores.get(pair_key)
if score is None:
score = _compare_fingerprints(keeper_fp, loser_fp)
if score < args.threshold:
print(f" SKIP [{loser_ext} {loser_size / 1024 / 1024:.1f}M sim={score:.3f} to keeper] {loser_path}")
skipped_transitive += 1
continue
print(f" DELETE [{loser_ext} {loser_size / 1024 / 1024:.1f}M sim={score:.3f}] {loser_path}")
delete_targets.append((beets_id, loser_path))
print()
if skipped_transitive:
print(f"[fuzzy-dupes] skipped {skipped_transitive} transitive false-positives "
f"(chain-grouped but sim<{args.threshold} to keeper)\n")
if not args.apply:
print(f"\n[fuzzy-dupes] DRY RUN — pass --apply to delete {len(delete_targets)} files")
return 0
print(f"\n[fuzzy-dupes] APPLY: deleting {len(delete_targets)} files via beet remove -d -f")
failed = 0
for beets_id, path in delete_targets:
# Escape regex metacharacters for path:: regex query
escaped = re.escape(path)
result = subprocess.run(
["beet", "remove", "-d", "-f", f"path::{escaped}"],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f" FAILED: {path}{result.stderr.strip()}", file=sys.stderr)
failed += 1
print(f"[fuzzy-dupes] done. {len(delete_targets) - failed} deleted, {failed} failed.")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# Find audio files in the library that have no ALBUM tag, set ALBUM to
# "{TITLE} - Single" — matches Spotify / Apple Music's convention for
# single-track releases. Otherwise these tracks land as [Unknown Album]
# in Navidrome.
#
# Usage:
# fix-empty-album.sh # dry run (default)
# fix-empty-album.sh --apply # actually rewrite tags
#
# Bandcamp single-track downloads are the most common source of this
# issue (Bandcamp serves single tracks with no ALBUM tag). New downloads
# via sync-bandcamp.py are auto-tagged; this script handles backfill.
set -u
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/fix-empty-album-$(date +%Y%m%d-%H%M%S).log
mkdir -p "$(dirname "$LOG")"
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
ND_BASE="${ND_BASE:-http://navidrome:4533}"
ND_USER="${ND_USER:-andrew}"
ND_PASS="${ND_PASS:-}"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
if [[ $APPLY -eq 1 ]]; then
log "=== FIX EMPTY ALBUM (APPLY) ==="
else
log "=== FIX EMPTY ALBUM (DRY RUN — pass --apply to write) ==="
fi
FIXED=0
SKIPPED=0
while IFS= read -r -d '' f; do
ext="${f,,}"; ext="${ext##*.}"
album="" ; title=""
case "$ext" in
flac)
album=$(metaflac --show-tag=ALBUM "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
title=$(metaflac --show-tag=TITLE "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
;;
mp3)
album=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TALB[^:]*: //p' | head -1)
title=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TIT2[^:]*: //p' | head -1)
;;
*) continue ;;
esac
if [[ -n "$album" ]]; then
continue # already has an album, leave it alone
fi
if [[ -z "$title" ]]; then
log "[skip-no-title] $f"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Some titles have a leading "NN " track-number prefix baked in from a
# bad filename parse (e.g. title="01 Domestic Violence"). If we use the
# title as-is, the album becomes "01 Domestic Violence - Single" which
# is broken. Strip leading 1-3 digits + space, and also fix the title
# tag in place so it isn't shown with the prefix in clients.
clean_title=$(echo "$title" | sed -E 's/^[[:space:]]*[0-9]{1,3}[[:space:]]+//')
if [[ "$clean_title" != "$title" && -n "$clean_title" ]]; then
log " trimmed leading track-number from title: \"$title\" -> \"$clean_title\""
if [[ $APPLY -eq 1 ]]; then
case "$ext" in
flac) metaflac --remove-tag=TITLE --set-tag="TITLE=${clean_title}" "$f" 2>>"$LOG" ;;
mp3) id3v2 --TIT2 "${clean_title}" "$f" 2>>"$LOG" ;;
esac
fi
title="$clean_title"
fi
new_album="${title} - Single"
log "[$( [[ $APPLY -eq 1 ]] && echo SET || echo WOULD-SET )] album=\"${new_album}\" - $f"
if [[ $APPLY -eq 1 ]]; then
case "$ext" in
flac) metaflac --set-tag="ALBUM=${new_album}" "$f" 2>>"$LOG" ;;
mp3) id3v2 --TALB "${new_album}" "$f" 2>>"$LOG" ;;
esac
fi
FIXED=$((FIXED + 1))
done < <(find ${MUSIC_DATA_DIR:-/data/music}/Library -type f \( -iname "*.flac" -o -iname "*.mp3" \) -print0 2>/dev/null)
log ""
log "=== Summary: $FIXED fixed, $SKIPPED skipped (no title) ==="
if [[ $APPLY -eq 1 && $FIXED -gt 0 ]]; then
log "Syncing beets DB from new tags..."
beet update 2>&1 | tail -3 >> "$LOG"
log "Moving files to reflect new album in path templates..."
beet move 2>&1 | tail -3 >> "$LOG"
log "Triggering Navidrome rescan..."
curl -s -G "$ND_BASE/rest/startScan.view" \
--data-urlencode "u=$ND_USER" \
--data-urlencode "p=$ND_PASS" \
--data-urlencode 'v=1.16.0' \
--data-urlencode 'c=fix-empty-album' \
--data-urlencode 'f=json' \
--data-urlencode 'fullScan=true' >> "$LOG" 2>&1
fi
log "=== Done. Log: $LOG ==="
+123
View File
@@ -0,0 +1,123 @@
#!/bin/bash
# fix-genre.sh — quickly override the GENRE tag for every track by an artist.
#
# Use when spotify-genre.py picks something wrong (e.g. it tagged Yasuha as
# "Asian Music" when "Anime; Soundtrack" is what you actually want), or when
# you want to set a genre on an artist Spotify doesn't have data for.
#
# The new value is written to file tags AND beets's DB. The next nightly
# spotify-genre run will leave these alone (they already match the whitelist
# format), unless you pass --force.
#
# Usage:
# fix-genre.sh "<artist>" "<Genre1; Genre2; ...>" # apply
# fix-genre.sh --dry "<artist>" "<Genre1; ...>" # preview only
#
# Examples:
# sudo fix-genre.sh "Yasuha" "Anime; Soundtrack"
# sudo fix-genre.sh "Aphex Twin" "IDM; Electronic; Ambient"
# sudo fix-genre.sh --dry "Mac DeMarco" "Indie; Indie Rock"
set -u
DRY=0
if [[ "${1:-}" == "--dry" ]]; then
DRY=1
shift
fi
ARTIST="${1:?Usage: $0 [--dry] <artist> <Genre1; Genre2; ...>}"
GENRE="${2:?Usage: $0 [--dry] <artist> <Genre1; Genre2; ...>}"
LIBRARY=${MUSIC_DATA_DIR:-/data/music}/Library
# Pull paths via beets (in-process, same container).
mapfile -t paths < <(beet ls -f '$path' "artist:$ARTIST" 2>/dev/null)
if [[ "${#paths[@]}" -eq 0 ]]; then
echo "[fix-genre] no tracks found in beets for artist:$ARTIST"
exit 1
fi
echo "[fix-genre] artist=\"$ARTIST\""
echo "[fix-genre] new GENRE=\"$GENRE\""
echo "[fix-genre] ${#paths[@]} track(s) match"
[[ $DRY -eq 1 ]] && echo "[fix-genre] DRY RUN — no tags will be written" && exit 0
ok=0
fail=0
for cp in "${paths[@]}"; do
hp="${LIBRARY}${cp#/music}"
if [[ ! -f "$hp" ]]; then
echo " MISS $hp"
fail=$((fail + 1))
continue
fi
case "${hp,,}" in
*.flac)
metaflac --remove-tag=GENRE "$hp" 2>/dev/null
metaflac --set-tag="GENRE=$GENRE" "$hp" 2>/dev/null && ok=$((ok + 1)) || fail=$((fail + 1))
metaflac --remove-tag=GENRE_LOCK "$hp" 2>/dev/null
metaflac --set-tag="GENRE_LOCK=1" "$hp" 2>/dev/null
;;
*.mp3)
# mid3v2 / id3v2 don't have a clean way to set TCON via plain id3v2 here,
# so use mutagen via a one-liner.
python3 - "$hp" "$GENRE" <<'PY' && ok=$((ok + 1)) || fail=$((fail + 1))
import sys
from mutagen.id3 import ID3, TCON, TXXX, ID3NoHeaderError
path, g = sys.argv[1], sys.argv[2]
try:
t = ID3(path)
except ID3NoHeaderError:
t = ID3()
t.delall("TCON")
t.add(TCON(encoding=3, text=g))
t.delall("TXXX:GENRE_LOCK")
t.add(TXXX(encoding=3, desc="GENRE_LOCK", text="1"))
t.save(path)
PY
;;
*.m4a|*.mp4)
python3 - "$hp" "$GENRE" <<'PY' && ok=$((ok + 1)) || fail=$((fail + 1))
import sys
from mutagen.mp4 import MP4
path, g = sys.argv[1], sys.argv[2]
m = MP4(path)
m["\xa9gen"] = g
m.save()
PY
;;
*.opus|*.ogg)
python3 - "$hp" "$GENRE" <<'PY' && ok=$((ok + 1)) || fail=$((fail + 1))
import sys
from mutagen import File as MFile
path, g = sys.argv[1], sys.argv[2]
m = MFile(path)
m.tags["genre"] = [g]
m.save()
PY
;;
*)
echo " SKIP unsupported format: $hp"
fail=$((fail + 1))
;;
esac
done
echo "[fix-genre] tagged ok=$ok, failed=$fail"
# Sync beets DB so its $genres column matches the file tags.
# `-q` is NOT a flag for `beet update` (only `beet ls`); leaving it off
# lets beets actually apply the diff non-interactively.
echo "[fix-genre] syncing beets DB"
beet update "artist:$ARTIST" 2>&1 | grep -E '^\s+[+-]' || true
# Verify
echo "[fix-genre] new state:"
beet ls -f ' $title — $genres' "artist:$ARTIST" | head -5
remaining=$(beet ls "artist:$ARTIST" | wc -l)
shown=5
[[ "$remaining" -gt "$shown" ]] && echo " ...and $((remaining - shown)) more"
echo "[fix-genre] done. Trigger a Navidrome scan to surface the change in the UI."
+514
View File
@@ -0,0 +1,514 @@
#!/usr/bin/env python3
"""
fix-track-metadata.py — interactively rewrite a single track's metadata
from a Spotify or iTunes URL.
Prompts for an audio file path and a Spotify/iTunes URL, then:
1. Fetches canonical metadata (artist, album, title, track#, date, cover).
2. Writes tags to the file (artist semicolon-joined, albumartist primary
only — matches the rest of the booksandtunes pipeline).
3. Strips SoundCloud / upload-junk frames (WWWAUDIOFILE, comment,
compatible_brands, major_brand, minor_version).
4. Replaces embedded cover with the URL's artwork and drops a cover.jpg
sidecar in the album folder.
5. Runs `beet update` so beets's DB matches.
6. Runs `beet move` to relocate the file under
/music/<albumartist>/<album>/<title>.<ext>.
7. Strips GENRE and runs spotify-genre.py to refill from the artist's
Spotify per-artist genres (use --keep-genre to skip this).
8. Triggers a Navidrome rescan via the Subsonic API.
Usage:
./fix-track-metadata.py # interactive
./fix-track-metadata.py <file> <url> # non-interactive
./fix-track-metadata.py --keep-genre <f> <u> # leave existing GENRE alone
The <file> arg accepts any of:
- absolute host path: $MUSIC_DATA_DIR/Library/Foo/Bar.flac
- beets container path: /music/Foo/Bar.flac
- library-relative path: Foo/Bar.flac
(resolved against $MUSIC_DATA_DIR/Library/)
Supported URLs:
https://open.spotify.com/track/<id>...
https://open.spotify.com/album/<id>... (single-track albums only)
https://music.apple.com/.../id<col>?i=<track> (iTunes track URL)
"""
import sys, os, re, json, base64, argparse, subprocess, unicodedata, urllib.parse, urllib.request
from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError, TPE1, TPE2, TALB, TIT2, TRCK, TDRC, TCON, APIC
from mutagen.flac import FLAC, Picture
from mutagen.mp4 import MP4, MP4Cover
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
_ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
_MUSIC_DATA_DIR = os.environ.get("MUSIC_DATA_DIR", "/data/music")
LIBRARY = f"{_MUSIC_DATA_DIR}/Library"
# Still "/music" through the migration's Stage 0-3 transitional beets mount;
# revisit at Stage 4 once beets' directory: becomes MUSIC_DATA_DIR/Library.
CONTAINER_PREFIX = "/music"
SPOTIFY_ENV = f"{_ALEMBIC_CONFIG_DIR}/pipeline/_spotify.env"
SPOTIFY_GENRE = f"{os.environ.get('PIPELINE_DIR', '/app/pipeline')}/lib/spotify-genre.py"
def _load_navidrome_env():
path = f"{_ALEMBIC_CONFIG_DIR}/pipeline/navidrome/admin.env"
env = {}
if os.path.exists(path):
for line in open(path):
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v.strip().strip("'").strip('"')
return env
_nd_env = _load_navidrome_env()
NAVIDROME_URL = f"{_nd_env.get('ND_BASE', 'http://navidrome:4533')}/rest/startScan.view"
NAVIDROME_USER = _nd_env.get("ND_USER", "andrew")
NAVIDROME_PASS = _nd_env.get("ND_PASS", "")
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
JUNK_TXXX = {"WWWAUDIOFILE", "comment", "compatible_brands",
"major_brand", "minor_version"}
# ---------- Spotify ----------
def _spotify_token():
env = {}
with open(SPOTIFY_ENV) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v.strip().strip("'").strip('"')
cid, csec = env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"]
creds = base64.b64encode(f"{cid}:{csec}".encode()).decode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=urllib.parse.urlencode({"grant_type": "client_credentials"}).encode(),
headers={"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded"})
return json.loads(urllib.request.urlopen(req, timeout=15).read())["access_token"]
def _spotify_get(path, token):
req = urllib.request.Request(f"https://api.spotify.com/v1{path}",
headers={"Authorization": f"Bearer {token}"})
return json.loads(urllib.request.urlopen(req, timeout=15).read())
def fetch_spotify_track(track_id):
tok = _spotify_token()
t = _spotify_get(f"/tracks/{track_id}", tok)
a = t["album"]
return {
"title": t["name"],
"artists": [ar["name"] for ar in t["artists"]],
"album": a["name"],
"albumartists": [ar["name"] for ar in a["artists"]],
"date": a["release_date"],
"track": t["track_number"],
"tracktotal": a["total_tracks"],
"cover_url": a["images"][0]["url"] if a["images"] else None,
}
def fetch_spotify_album(album_id):
tok = _spotify_token()
a = _spotify_get(f"/albums/{album_id}", tok)
if a["total_tracks"] != 1:
raise SystemExit(f"album has {a['total_tracks']} tracks — pass a track URL instead")
t = a["tracks"]["items"][0]
return {
"title": t["name"],
"artists": [ar["name"] for ar in t["artists"]],
"album": a["name"],
"albumartists": [ar["name"] for ar in a["artists"]],
"date": a["release_date"],
"track": 1,
"tracktotal": 1,
"cover_url": a["images"][0]["url"] if a["images"] else None,
}
# ---------- iTunes ----------
def fetch_itunes_track(track_id):
url = f"https://itunes.apple.com/lookup?id={track_id}&entity=song"
data = json.loads(urllib.request.urlopen(url, timeout=15).read())
items = [r for r in data.get("results", []) if r.get("wrapperType") == "track"]
if not items:
raise SystemExit(f"iTunes lookup returned no track for id={track_id}")
r = items[0]
# iTunes only gives one artist string — the project convention is
# semicolon-joined for ARTIST. The user can split manually after if needed.
artists = [r["artistName"]]
cover = r.get("artworkUrl100", "")
if cover:
# 100x100 → 600x600 by URL substitution (well-known iTunes hack)
cover = re.sub(r"/\d+x\d+bb\.jpg$", "/600x600bb.jpg", cover)
return {
"title": r["trackName"],
"artists": artists,
"album": r["collectionName"],
"albumartists": artists,
"date": (r.get("releaseDate") or "")[:10],
"track": r.get("trackNumber", 1),
"tracktotal": r.get("trackCount", 1),
"cover_url": cover or None,
}
# ---------- URL dispatch ----------
def fetch_metadata(url):
m = re.search(r"open\.spotify\.com/track/([A-Za-z0-9]+)", url)
if m:
return fetch_spotify_track(m.group(1))
m = re.search(r"open\.spotify\.com/album/([A-Za-z0-9]+)", url)
if m:
return fetch_spotify_album(m.group(1))
m = re.search(r"music\.apple\.com/.+?[?&]i=(\d+)", url)
if m:
return fetch_itunes_track(m.group(1))
m = re.search(r"music\.apple\.com/.+?/song/[^/]+/(\d+)", url)
if m:
return fetch_itunes_track(m.group(1))
raise SystemExit(f"unrecognized URL: {url}")
# ---------- Tagging ----------
def write_tags(filepath, meta):
"""Write artist/albumartist/album/title/track/date and embedded cover.
Drops watermark/junk TXXX frames as a side effect.
"""
artist_joined = "; ".join(meta["artists"])
albumartist = meta["albumartists"][0] if meta["albumartists"] else meta["artists"][0]
cover_bytes = _download_cover(meta["cover_url"]) if meta["cover_url"] else None
date = meta["date"] # YYYY-MM-DD or YYYY
m = MFile(filepath)
if m is None:
raise SystemExit(f"unsupported audio format: {filepath}")
if isinstance(m, VORBIS_LIKE):
_write_vorbis(m, artist_joined, albumartist, meta, date, cover_bytes)
elif isinstance(m, MP4):
_write_mp4(m, artist_joined, albumartist, meta, date, cover_bytes)
else:
_write_id3(filepath, artist_joined, albumartist, meta, date, cover_bytes)
def _write_vorbis(m, artist_joined, albumartist, meta, date, cover_bytes):
m.tags["ARTIST"] = artist_joined
m.tags["ALBUMARTIST"] = albumartist
m.tags["ALBUM"] = meta["album"]
m.tags["TITLE"] = meta["title"]
m.tags["TRACKNUMBER"] = str(meta["track"])
m.tags["TRACKTOTAL"] = str(meta["tracktotal"])
m.tags["DATE"] = date
if cover_bytes and isinstance(m, FLAC):
m.clear_pictures()
pic = Picture()
pic.type = 3 # Cover (front)
pic.mime = "image/jpeg"
pic.data = cover_bytes
m.add_picture(pic)
m.save()
def _write_mp4(m, artist_joined, albumartist, meta, date, cover_bytes):
m.tags["\xa9ART"] = artist_joined
m.tags["aART"] = albumartist
m.tags["\xa9alb"] = meta["album"]
m.tags["\xa9nam"] = meta["title"]
m.tags["\xa9day"] = date
m.tags["trkn"] = [(int(meta["track"]), int(meta["tracktotal"]))]
if cover_bytes:
m.tags["covr"] = [MP4Cover(cover_bytes, imageformat=MP4Cover.FORMAT_JPEG)]
m.save()
def _write_id3(filepath, artist_joined, albumartist, meta, date, cover_bytes):
try:
t = ID3(filepath)
except ID3NoHeaderError:
t = ID3()
t.delall("TPE1"); t.add(TPE1(encoding=3, text=artist_joined))
t.delall("TPE2"); t.add(TPE2(encoding=3, text=albumartist))
t.delall("TALB"); t.add(TALB(encoding=3, text=meta["album"]))
t.delall("TIT2"); t.add(TIT2(encoding=3, text=meta["title"]))
t.delall("TRCK"); t.add(TRCK(encoding=3, text=f"{meta['track']}/{meta['tracktotal']}"))
t.delall("TDRC"); t.add(TDRC(encoding=3, text=date))
# Strip junk TXXX frames
for frame in list(t.getall("TXXX")):
if frame.desc in JUNK_TXXX:
t.delall(f"TXXX:{frame.desc}")
if cover_bytes:
t.delall("APIC")
t.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover_bytes))
t.save(filepath)
def _download_cover(url):
if not url:
return None
return urllib.request.urlopen(url, timeout=20).read()
# ---------- Beets / Navidrome ----------
def host_to_container(host_path):
if host_path.startswith(LIBRARY):
return CONTAINER_PREFIX + host_path[len(LIBRARY):]
return host_path
def container_to_host(cp):
if cp.startswith(CONTAINER_PREFIX + "/"):
return LIBRARY + cp[len(CONTAINER_PREFIX):]
return cp
def resolve_input_path(arg):
"""Accept a host path, container path, or library-relative path; return
the absolute host path that actually exists on disk.
Falls back through NFC/NFD Unicode normalizations because terminal pastes
of Japanese/Korean/etc. paths often come in decomposed form even when the
filesystem stores them composed (or vice versa)."""
arg = arg.strip().strip('"').strip("'")
if arg.startswith(CONTAINER_PREFIX + "/"):
candidate = container_to_host(arg)
elif os.path.isabs(arg):
candidate = arg
else:
candidate = os.path.join(LIBRARY, arg)
if os.path.exists(candidate):
return candidate
for form in ("NFC", "NFD", "NFKC", "NFKD"):
norm = unicodedata.normalize(form, candidate)
if os.path.exists(norm):
return norm
# Last resort: walk component by component, picking the entry whose NFC
# form matches our NFC form. Handles the common case where one directory
# in the path differs in normalization but the rest is fine.
if not candidate.startswith("/"):
return candidate
parts = candidate.lstrip("/").split("/")
cur = "/"
for want in parts:
if not os.path.isdir(cur):
return candidate
want_nfc = unicodedata.normalize("NFC", want)
match = next(
(n for n in os.listdir(cur)
if unicodedata.normalize("NFC", n) == want_nfc),
None)
if match is None:
return candidate
cur = os.path.join(cur, match)
return cur
def beets_id_for(host_path):
cp = host_to_container(host_path)
r = subprocess.run(
["beet", "ls", "-f", "$id|||$path", f"path:{cp}"],
capture_output=True, text=True, check=True)
for line in r.stdout.splitlines():
if "|||" in line:
id_str, p = line.split("|||", 1)
if p == cp:
return int(id_str)
return None
def beet_update(beets_id):
# `beet update` prompts y/n on each diff; `yes y` auto-confirms.
subprocess.run(
f"yes y | beet update id:{beets_id}",
shell=True, check=False)
def beet_move(beets_id):
"""Beets path-format relocates the file under LIBRARY/<albumartist>/<album>/."""
subprocess.run(["beet", "move", f"id:{beets_id}"], check=False)
def beet_path(beets_id):
r = subprocess.run(
["beet", "ls", "-f", "$path", f"id:{beets_id}"],
capture_output=True, text=True, check=True)
cp = r.stdout.strip()
return container_to_host(cp) if cp else None
def strip_genre(filepath):
"""Blank GENRE so spotify-genre.py will refill it (skip-existing logic)."""
m = MFile(filepath)
if m is None or m.tags is None:
return
if isinstance(m, VORBIS_LIKE):
if "genre" in m.tags:
del m.tags["genre"]
m.save()
elif isinstance(m, MP4):
if "\xa9gen" in m.tags:
del m.tags["\xa9gen"]
m.save()
else:
try:
t = ID3(filepath)
t.delall("TCON")
t.save(filepath)
except ID3NoHeaderError:
pass
def refill_genre(beets_id):
subprocess.run(
[SPOTIFY_GENRE, "--apply", "--force", "--query", f"id:{beets_id}"],
check=False)
def write_cover_sidecar(filepath, cover_bytes):
if not cover_bytes:
return
sidecar = os.path.join(os.path.dirname(filepath), "cover.jpg")
with open(sidecar, "wb") as f:
f.write(cover_bytes)
def navidrome_scan():
qs = urllib.parse.urlencode({
"u": NAVIDROME_USER, "p": NAVIDROME_PASS,
"v": "1.16.0", "c": "fix-track-metadata", "f": "json",
})
try:
urllib.request.urlopen(f"{NAVIDROME_URL}?{qs}", timeout=10).read()
print("[navidrome] rescan triggered")
except Exception as e:
print(f"[navidrome] rescan failed: {e}")
# ---------- Main ----------
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("file", nargs="?",
help="audio file: host path, /music/... container path, "
"or library-relative path (Foo/Bar.flac)")
ap.add_argument("url", nargs="?", help="Spotify or iTunes URL")
ap.add_argument("--keep-genre", action="store_true",
help="don't strip + refill GENRE (default: refill via spotify-genre.py)")
args = ap.parse_args()
# Tab-completion on the file prompt — pasting unicode paths is finicky,
# tab-completing them is not.
try:
import readline
readline.set_completer_delims(" \t\n;")
def _path_complete(text, state):
base = text if text.startswith("/") else os.path.join(LIBRARY, text)
d, prefix = os.path.split(base)
if not os.path.isdir(d):
return None
matches = [n for n in os.listdir(d) if n.startswith(prefix)]
matches.sort()
if state >= len(matches):
return None
full = os.path.join(d, matches[state])
# Strip the LIBRARY prefix back off if user was typing relative
display = full[len(LIBRARY) + 1:] if (
not text.startswith("/") and full.startswith(LIBRARY + "/")) else full
return display + ("/" if os.path.isdir(full) else "")
readline.set_completer(_path_complete)
readline.parse_and_bind("tab: complete")
except ImportError:
pass
raw_file = args.file or input("file path: ").strip()
url = args.url or input("spotify/itunes URL: ").strip()
filepath = resolve_input_path(raw_file)
if not os.path.isfile(filepath):
# Diagnostics: show the bytes we tried + what's actually in the parent.
print(f"[error] not a file: {filepath}", file=sys.stderr)
print(f"[error] input bytes: {raw_file.encode('utf-8')!r}", file=sys.stderr)
print(f"[error] resolved bytes: {filepath.encode('utf-8')!r}", file=sys.stderr)
parent = os.path.dirname(filepath)
if os.path.isdir(parent):
print(f"[error] parent dir contents:", file=sys.stderr)
for n in sorted(os.listdir(parent)):
print(f" {n!r} ({n.encode('utf-8')!r})", file=sys.stderr)
else:
# Walk up to the deepest existing ancestor and list it
anc = parent
while anc and anc != "/" and not os.path.isdir(anc):
anc = os.path.dirname(anc)
if anc and os.path.isdir(anc):
print(f"[error] deepest existing dir: {anc}", file=sys.stderr)
print(f"[error] its contents:", file=sys.stderr)
for n in sorted(os.listdir(anc)):
print(f" {n!r}", file=sys.stderr)
raise SystemExit(1)
print(f"[fetch] {url}")
meta = fetch_metadata(url)
print(f" title: {meta['title']}")
print(f" artists: {'; '.join(meta['artists'])}")
print(f" album: {meta['album']}")
print(f" albumartist: {meta['albumartists'][0] if meta['albumartists'] else '?'}")
print(f" date: {meta['date']}")
print(f" track: {meta['track']}/{meta['tracktotal']}")
print(f" cover: {meta['cover_url'] or '(none)'}")
if not args.file:
ok = input("apply? [y/N] ").strip().lower()
if ok != "y":
print("aborted")
return
print("[tags] writing")
cover_bytes = _download_cover(meta["cover_url"]) if meta["cover_url"] else None
write_tags(filepath, meta)
write_cover_sidecar(filepath, cover_bytes)
bid = beets_id_for(filepath)
if bid is None:
print("[beets] file not in beets DB — skipping update/move/genre")
else:
print(f"[beets] update id:{bid}")
beet_update(bid)
print(f"[beets] move id:{bid}")
beet_move(bid)
new_path = beet_path(bid)
if new_path:
filepath = new_path
# Re-drop sidecar in the (possibly new) folder
write_cover_sidecar(filepath, cover_bytes)
print(f"[beets] now at {filepath}")
if not args.keep_genre:
print("[genre] stripping + refilling via spotify-genre.py")
strip_genre(filepath)
beet_update(bid)
refill_genre(bid)
beet_update(bid)
navidrome_scan()
print("[done]")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# gen-djmix-playlist.sh — regenerate djmix.m3u8 from the configured album list.
#
# Reads ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/djmix-albums.txt (one beets query per line),
# collects all matching track paths, deduplicates, and writes
# ${MUSIC_DATA_DIR:-/data/music}/playlists/djmix.m3u8.
#
# Azuracast imports this playlist so DJ mixes can be excluded from general
# rotation. Navidrome also picks it up as a regular playlist.
#
# Runs daily from cron. Safe to run manually anytime.
set -u
CONFIG=${ALEMBIC_CONFIG_DIR:-/config}/pipeline/djmix-albums.txt
PLAYLIST_DIR=${MUSIC_DATA_DIR:-/data/music}/playlists
OUT="${PLAYLIST_DIR}/djmix.m3u8"
LOG_DIR=${ALEMBIC_CONFIG_DIR:-/config}/logs
LOG="${LOG_DIR}/djmix-$(date +%Y%m%d).log"
mkdir -p "$LOG_DIR" "$PLAYLIST_DIR"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
log "=== Regenerating djmix.m3u8 ==="
TRACKS=$(mktemp)
while IFS= read -r query; do
[[ -z "$query" || "$query" == "#"* ]] && continue
# Split on | to support multi-field queries (e.g. "albumartist::Foo | album:Bar").
# Each |-separated part becomes a separate beet query term (ANDed together).
# Single-field lines (no |) are passed as one arg so commas in album names
# don't get misinterpreted by beet's query parser.
IFS='|' read -ra _terms <<< "$query"
_args=()
for _t in "${_terms[@]}"; do
# strip leading/trailing whitespace
_t="${_t#"${_t%%[![:space:]]*}"}"
_t="${_t%"${_t##*[![:space:]]}"}"
[[ -n "$_t" ]] && _args+=("$_t")
done
beet ls -f '$path' "${_args[@]}" 2>>"$LOG" >> "$TRACKS"
done < "$CONFIG"
# Deduplicate and sort
SORTED=$(sort -u "$TRACKS")
rm -f "$TRACKS"
COUNT=$(echo -n "$SORTED" | grep -c '^' || true)
if [[ "$COUNT" -gt 0 ]]; then
{
echo "#EXTM3U"
echo "$SORTED"
} > "$OUT"
log "djmix.m3u8 updated with $COUNT tracks"
else
log "WARNING: no tracks found — djmix.m3u8 not updated"
fi
log "=== Finished ==="
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# gen-vgm-playlist.sh — regenerate vgm.m3u8 from all tracks tagged with
# genre "Game Soundtrack" in the beets library.
#
# Navidrome picks this up as a regular playlist. Safe to run manually anytime.
set -u
PLAYLIST_DIR=${MUSIC_DATA_DIR:-/data/music}/playlists
OUT="${PLAYLIST_DIR}/vgm.m3u8"
LOG_DIR=${ALEMBIC_CONFIG_DIR:-/config}/logs
LOG="${LOG_DIR}/vgm-$(date +%Y%m%d).log"
mkdir -p "$LOG_DIR" "$PLAYLIST_DIR"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
log "=== Regenerating vgm.m3u8 ==="
SORTED=$(beet ls -f '$path' 'genres:Game' 2>>"$LOG" | sort -u)
COUNT=$(echo -n "$SORTED" | grep -c '^' || true)
if [[ "$COUNT" -gt 0 ]]; then
{
echo "#EXTM3U"
echo "$SORTED"
} > "$OUT"
log "vgm.m3u8 updated with $COUNT tracks"
else
log "WARNING: no tracks found — vgm.m3u8 not updated"
fi
log "=== Finished ==="
+1000
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# ${PIPELINE_DIR:-/app/pipeline}/lib/mb-tags.sh
#
# Shared helper exposing `strip_mb_tags <dir> [log_file]`.
# Sourced by lib/strip-mb-tags.sh (Sunday library-wide scan) and by
# import-track.sh (per-import scoped scan).
#
# Strips MusicBrainz IDs and release-identification tags from FLAC files
# that carry at least one trigger tag. Navidrome's BFR scanner uses these
# tags to differentiate releases of the same album; when one track in an
# album has them and the rest don't, the album card splits.
#
# Usage: strip_mb_tags <dir> [log_file]
# Returns: number of files stripped, via stdout
_MB_STRIP_TAGS=(
MUSICBRAINZ_ALBUMID
MUSICBRAINZ_RELEASEGROUPID
MUSICBRAINZ_RELEASETRACKID
MUSICBRAINZ_ALBUMARTISTID
MUSICBRAINZ_ARTISTID
MUSICBRAINZ_ALBUMSTATUS
MUSICBRAINZ_ALBUMTYPE
MUSICBRAINZ_TRACKID
MUSICBRAINZ_WORKID
RELEASESTATUS
RELEASETYPE
RELEASECOUNTRY
BARCODE
CATALOGNUMBER
LABEL
PUBLISHER
MEDIA
ORIGINALDATE
ASIN
ISRC
SCRIPT
LANGUAGE
MUSICBRAINZ_ALBUMCOMMENT
ALBUMCOMMENT
MUSICBRAINZ_DISCID
MUSICBRAINZ_TRMID
"ALBUM ARTIST"
ALBUM_ARTIST
ALBUMARTIST_CREDIT
ALBUMARTISTSORT
ALBUMARTISTS
ALBUM_ARTISTS
ALBUMARTISTS_CREDIT
ALBUMARTISTS_SORT
ARTIST_CREDIT
ARTISTSORT
ARTISTS
ARTISTS_CREDIT
ARTISTS_SORT
COMPOSERSORT
ACOUSTID_ID
ACOUSTID_FINGERPRINT
COMPILATION
YEAR
DISCSUBTITLE
DISCC
TOTALDISCS
TRACKC
TOTALTRACKS
)
_MB_STRIP_TRIGGERS=(
MUSICBRAINZ_ALBUMID
MUSICBRAINZ_RELEASEGROUPID
MUSICBRAINZ_TRACKID
BARCODE
CATALOGNUMBER
LABEL
ORIGINALDATE
ASIN
MUSICBRAINZ_ALBUMCOMMENT
ALBUMCOMMENT
)
strip_mb_tags() {
local dir="$1"
local logfile="${2:-/dev/null}"
local count=0
local f tt has_trigger args
if [[ -z "$dir" || ! -e "$dir" ]]; then
echo 0
return 0
fi
while IFS= read -r -d '' f; do
has_trigger=0
for tt in "${_MB_STRIP_TRIGGERS[@]}"; do
if metaflac --show-tag="$tt" "$f" 2>/dev/null | grep -q .; then
has_trigger=1
break
fi
done
if [[ $has_trigger -eq 1 ]]; then
args=()
for t in "${_MB_STRIP_TAGS[@]}"; do args+=("--remove-tag=$t"); done
metaflac "${args[@]}" "$f" 2>>"$logfile"
count=$((count + 1))
fi
done < <(find "$dir" -name "*.flac" -type f -print0 2>/dev/null)
echo "$count"
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# navidrome-scan.sh — trigger a Navidrome full scan ONLY if the music library
# share is healthy.
#
# Why this exists: with ND_SCANNER_PURGEMISSING=always (which we keep, so
# on-disk deletions propagate to playlists and out to Traktor), a full scan
# that runs while the QNAP NFS share is unavailable makes Navidrome see the
# entire library as "missing" and purge it — wiping every playlist. That is
# exactly what happened on 2026-06-01 and lost the manual dj-* playlists.
#
# This wrapper refuses to trigger the scan unless the share is mounted, a
# canary file is readable, and the library has a sane number of artist dirs.
# Skipping a scan is harmless (the next healthy run picks up changes); running
# one against a dead mount is catastrophic.
set -euo pipefail
: "${MUSIC_DATA_DIR:=/data/music}"
: "${ALEMBIC_CONFIG_DIR:=/config}"
NAVIDROME_ENV="$ALEMBIC_CONFIG_DIR/pipeline/navidrome/admin.env"
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
LIB="$MUSIC_DATA_DIR/Library"
CANARY="$LIB/.navidrome-canary"
MIN_ARTIST_DIRS=500 # library has ~1500; 500 is "clearly not wiped"
ND="${ND_BASE:-http://navidrome:4533}"
ND_USER="${ND_USER:-andrew}"
ND_PASS="${ND_PASS:-}"
log() { echo "[$(date -Iseconds)] navidrome-scan: $*"; }
# `mountpoint -q` on the host detected an unmounted/dead NFS share directly.
# Running inside a container against a bind-mounted view, that check doesn't
# apply (a bind mount has no distinct device number to detect) — the canary
# file + artist-dir-count checks below are what actually carry the
# 2026-06-01 protection now: an unhealthy/empty share fails both of them.
# (Confirmed as an intentional adaptation, not a silent drop — see alembic
# migration plan / MIGRATION.md.)
if [ ! -r "$CANARY" ]; then
log "ABORT: canary $CANARY missing/unreadable — share degraded — skipping scan"
exit 0
fi
n=$(find "$LIB" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
if [ "$n" -lt "$MIN_ARTIST_DIRS" ]; then
log "ABORT: only $n artist dirs under $LIB (< $MIN_ARTIST_DIRS) — library looks truncated — skipping scan"
exit 0
fi
log "library healthy ($n artist dirs) — triggering full scan"
resp=$(curl -s -G "$ND/rest/startScan.view" \
--data-urlencode "u=$ND_USER" --data-urlencode "p=$ND_PASS" \
--data-urlencode 'v=1.16.0' --data-urlencode 'c=cron' \
--data-urlencode 'f=json' --data-urlencode 'fullScan=true' || true)
# Don't log "scan triggered" on faith: a rotated password or Navidrome error
# would otherwise report success forever while no scan ever runs.
if echo "$resp" | grep -q '"status":"ok"'; then
log "scan triggered"
else
log "ERROR: scan trigger failed — response: ${resp:-<empty/no response>}"
exit 1
fi
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
normalize-artist-casing.py — enforce canonical artist-name casing in beets.
Reads $ALEMBIC_CONFIG_DIR/pipeline/artist-canonical.list (one canonical name per line,
`#` comments allowed). For each distinct $albumartist in the library that
matches a canonical name case-insensitively but differs in casing, runs
`beet modify` to rewrite the albumartist tag — which moves the album folder
to the canonical path as a side effect.
Idempotent. Cheap to run (one `beet ls` + one `beet modify` per mismatch).
Why: prevents Linux → Windows Syncthing case-conflicts. Linux treats
`Mall Grab/` and `MALL GRAB/` as distinct folders; Windows collapses them.
Usage:
normalize-artist-casing.py # dry-run, prints diffs
normalize-artist-casing.py --apply # do the work
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import time
CONFIG = f"{os.environ.get('ALEMBIC_CONFIG_DIR', '/config')}/pipeline/artist-canonical.list"
BEET = ["beet"]
def load_canonical(path: str) -> list[str]:
out: list[str] = []
with open(path, encoding="utf-8") as f:
for ln in f:
ln = ln.split("#", 1)[0].strip()
if ln:
out.append(ln)
return out
def all_albumartists() -> set[str]:
res = subprocess.run(BEET + ["ls", "-f", "$albumartist"],
capture_output=True, text=True, check=True)
return {ln.strip() for ln in res.stdout.splitlines() if ln.strip()}
def apply_fix(old: str, new: str, verbose: bool) -> bool:
query = f"albumartist::^{re.escape(old)}$"
cmd = BEET + ["modify", "-y", query, f"albumartist={new}"]
r = subprocess.run(cmd, capture_output=True, text=True)
if verbose:
sys.stdout.write(r.stdout)
if r.returncode != 0:
sys.stderr.write(f" ERROR ({old!r}{new!r}): {r.stderr.strip()}\n")
return False
return True
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--apply", action="store_true",
help="execute the renames (default: dry run)")
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
canonical = load_canonical(CONFIG)
by_lower: dict[str, str] = {}
for n in canonical:
key = n.lower()
if key in by_lower and by_lower[key] != n:
print(f"[{stamp}] WARN: {CONFIG} has two entries with same lowercased "
f"key: {by_lower[key]!r} and {n!r}; using the latter",
file=sys.stderr)
by_lower[key] = n
existing = all_albumartists()
mismatches: list[tuple[str, str]] = []
for aa in sorted(existing):
want = by_lower.get(aa.lower())
if want and aa != want:
mismatches.append((aa, want))
print(f"[{stamp}] normalize-artist-casing: {len(canonical)} canonical, "
f"{len(existing)} distinct album-artists in library, "
f"{len(mismatches)} mismatched")
if not mismatches:
return 0
for old, new in mismatches:
print(f" {old!r} -> {new!r}")
if not args.apply:
print(f"[{stamp}] dry run; pass --apply to execute")
return 0
fixed = 0
for old, new in mismatches:
if apply_fix(old, new, args.verbose):
fixed += 1
print(f"[{stamp}] done: {fixed}/{len(mismatches)} applied")
return 0 if fixed == len(mismatches) else 1
if __name__ == "__main__":
sys.exit(main())
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# notify-telegram.sh — send a message to the configured Telegram chat.
# Sources credentials from $ALEMBIC_CONFIG_DIR/pipeline/telegram/notify.env (chmod 600).
#
# Usage:
# echo "single line message" | notify-telegram.sh
# notify-telegram.sh "single line message"
# notify-telegram.sh < ${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log
#
# Telegram messages are capped at 4096 chars. Anything longer is truncated
# with a "...<truncated>" tail. Returns exit 0 on send-OK, non-zero otherwise.
set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CONFIG="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/telegram/notify.env"
if [[ ! -f "$CONFIG" ]]; then
echo "[notify-telegram] no config at $CONFIG" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$CONFIG"
: "${TG_BOT_TOKEN:?TG_BOT_TOKEN missing}"
: "${TG_CHAT_ID:?TG_CHAT_ID missing}"
# Read message from arg or stdin
if [[ $# -ge 1 ]]; then
MSG="$*"
else
MSG=$(cat)
fi
# Telegram caps at 4096 chars (UTF-8 codepoints). Trim conservatively at 3900.
# Use python for proper UTF-8 length handling.
MSG=$(python3 -c "
import sys
s = sys.argv[1]
if len(s) > 3900:
s = s[:3900] + '\n...<truncated>'
print(s, end='')
" "$MSG")
# Send via Bot API. Disable web-page preview and use plain text (no parse_mode)
# so log content with special chars doesn't get interpreted as Markdown.
resp=$(curl -s --max-time 15 -X POST \
"https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TG_CHAT_ID}" \
--data-urlencode "text=${MSG}" \
--data-urlencode "disable_web_page_preview=true")
ok=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin).get('ok',False))" 2>/dev/null || echo False)
if [[ "$ok" != "True" ]]; then
echo "[notify-telegram] send failed: $resp" >&2
exit 2
fi
exit 0
+390
View File
@@ -0,0 +1,390 @@
#!/bin/bash
# pipeline-status.sh — daily health check for the music pipeline.
#
# Writes a one-screen summary to ${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log (overwritten daily)
# and ships it to Telegram via notify-telegram.sh.
#
# Reports on:
# - Sibling service reachability (slskd, navidrome) via HTTP, not docker ps —
# alembic has no Docker socket access
# - Today's runs: playlist syncs, Bandcamp sync, manual imports, dedup
# - Weekly maintenance freshness: strip-mb-tags, strip-watermark-art,
# scrub-watermark-text, clean-sldl-index, spotify-genre
# - Daily dedup freshness
# - Monthly mp3→flac upgrade freshness
# - Auth & state: Bandcamp cookie expiry, Qobuz token liveness, VPN egress
# - Storage, Navidrome scan freshness, library size by format
#
# Only flags SCRIPT-level failures, not per-file Soulseek errors (those are
# normal — peers go offline, connections reset).
set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
OUT=${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log
TODAY=$(date +%Y%m%d)
NOW_TS=$(date +%s)
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
ND_BASE="${ND_BASE:-http://navidrome:4533}"
ND_USER="${ND_USER:-andrew}"
ND_PASS="${ND_PASS:-}"
# Tally counters for the header banner. Body collected first, then prepended.
OK=0
WARN=0
SKIP=0
mark_ok() { OK=$((OK+1)); printf " ✓ %s\n" "$*"; }
mark_warn() { WARN=$((WARN+1)); printf " ⚠ %s\n" "$*"; }
mark_skip() { SKIP=$((SKIP+1)); printf " ⊘ %s\n" "$*"; }
mark_info() { printf " %s\n" "$*"; }
section() { printf "\n▎ %s\n" "$*"; }
# Format an age in seconds as "Nh", "Nd", "Nw", "Nmo".
human_age() {
local s=$1
if (( s < 3600 )); then echo "$((s/60))m ago"
elif (( s < 86400 )); then echo "$((s/3600))h ago"
elif (( s < 86400*14 )); then echo "$((s/86400))d ago"
elif (( s < 86400*60 )); then echo "$((s/86400/7))w ago"
else echo "$((s/86400/30))mo ago"
fi
}
# Newest matching log → "<age_seconds>|<path>", empty if no match.
newest_log() {
local glob="$1"
local newest
newest=$(ls -1t $glob 2>/dev/null | head -1)
[[ -z "$newest" ]] && return
local mtime
mtime=$(stat -c %Y "$newest" 2>/dev/null) || return
echo "$((NOW_TS - mtime))|$newest"
}
# Per-playlist log status.
playlist_status() {
local log="$1"
if grep -q "=== Finished playlist run" "$log" 2>/dev/null; then
# awk picks just the count after "with"; sidesteps the "M3U" digit-3 trap
# that bites grep -oE '[0-9]+'.
local m3u
m3u=$(awk 'match($0, /updated with [0-9]+ tracks/) {
n=substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", n); last=n
} END { print last }' "$log")
echo "OK|${m3u:-0} tracks in M3U"
elif grep -q "=== Starting playlist run" "$log" 2>/dev/null; then
echo "WARN|started but never finished"
else
echo "WARN|no Starting line"
fi
}
bandcamp_status() {
local log="$1"
if grep -q "=== Bandcamp sync done" "$log" 2>/dev/null; then
local s
s=$(grep -oE "\[bandcamp\] === [0-9]+ new[^]]*" "$log" | tail -1 | sed 's/\[bandcamp\] === //; s/ ===$//')
echo "OK|${s:-completed}"
else
echo "WARN|started but never finished"
fi
}
manual_import_status() {
local log="$1"
local n
# || true, not || echo 0: grep -c already prints "0" on no-match (and exits
# 1), so || echo 0 produced a two-line "0\n0" that broke the -ge test.
n=$(grep -c "=== Finished manual import" "$log" 2>/dev/null || true)
if [[ "${n:-0}" -ge 1 ]]; then
echo "OK|$n run(s)"
else
echo "WARN|started but never finished"
fi
}
dedup_today_status() {
local log="$1"
if grep -q "=== Summary:" "$log" 2>/dev/null; then
# Format: "=== Summary: N dup groups (parens with commas), N kept, N to delete ==="
# Strip the wrapper and the parenthesized breakdown so the line stays short.
local s
s=$(grep "=== Summary:" "$log" | tail -1 \
| sed -E 's/.*=== Summary: //; s/ ===.*//; s/ \([^)]*\)//')
echo "OK|${s:-completed (no summary line)}"
else
echo "WARN|started but never finished"
fi
}
# ---- Build report ----
{
echo "__HEADER_PLACEHOLDER__"
# 1. Sibling service health
# alembic deliberately has no Docker socket access (nothing left needs it
# once sldl/beet run in-process), so these are HTTP reachability checks
# against each service's own endpoint rather than `docker ps` container
# state — arguably a better signal anyway (checks the service actually
# answers, not just that the container process exists).
section "Services"
check_http() {
local name="$1" url="$2"
if curl -s -o /dev/null --connect-timeout 3 --max-time 6 "$url"; then
mark_ok "$(printf '%-11s reachable' "$name")"
else
mark_warn "$(printf '%-11s unreachable at %s' "$name" "$url")"
fi
}
check_http slskd "http://gluetun:5030/"
check_http navidrome "http://navidrome:4533/rest/ping.view"
# 2. Today's runs
# Allowlist-based: only Spotify playlists, Bandcamp, manual import, and
# dedup count as "runs." Everything else (laptop-export, normalize-casing,
# gen-djmix, gen-vgm, fix-empty-album, fingerprint-index, backfill, replace,
# convert-m4a, fred-mixes, dj-import-*, weekly tag-hygiene) is either
# boring/silent maintenance or already covered in the Maintenance section.
section "Today's runs"
any_today=0
newest_dedup_today=$(ls -1t ${ALEMBIC_CONFIG_DIR:-/config}/logs/dedup-$TODAY-*.log 2>/dev/null | head -1)
# Known Spotify playlist names — derive from configs/ directory so adding a
# new playlist auto-includes it here without editing this script.
PLAYLIST_NAMES=$(ls ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/*.conf 2>/dev/null \
| xargs -n1 basename 2>/dev/null \
| sed 's/\.conf$//' \
| grep -v '^_' || true)
for log in ${ALEMBIC_CONFIG_DIR:-/config}/logs/*-$TODAY.log; do
[[ -f "$log" ]] || continue
name=$(basename "$log" .log)
short=${name%-$TODAY}
case "$short" in
manual-import)
any_today=1
IFS='|' read -r tag detail < <(manual_import_status "$log") ;;
bandcamp)
any_today=1
IFS='|' read -r tag detail < <(bandcamp_status "$log") ;;
*)
# Is this a Spotify playlist log? Check against PLAYLIST_NAMES.
if echo "$PLAYLIST_NAMES" | grep -qxF "$short"; then
any_today=1
IFS='|' read -r tag detail < <(playlist_status "$log")
else
continue # not a "run" — silent / belongs in Maintenance / irrelevant
fi
;;
esac
line=$(printf '%-13s %s' "$short" "$detail")
if [[ "$tag" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
done
if [[ -n "$newest_dedup_today" ]]; then
any_today=1
IFS='|' read -r tag detail < <(dedup_today_status "$newest_dedup_today")
line=$(printf '%-13s %s' "dedup" "$detail")
if [[ "$tag" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
fi
[[ "$any_today" -eq 0 ]] && mark_info "(nothing has run yet today)"
# 2b. Nightly download tally — count tracks added to beets in the last 24h.
# Captures the overnight playlist sweep + any daytime Bandcamp/manual imports.
# Uses beets' `added` timestamp, so it's accurate regardless of how files
# arrived (Spotify pipeline, Bandcamp, manual). Per-playlist breakdown
# follows when ≥1 track was added with that grouping today.
ADDED_TODAY=$(beet ls -f '$grouping' \
"added:$(date +%Y-%m-%d).." 2>/dev/null || true)
added_total=$(echo -n "$ADDED_TODAY" | grep -c '^' || true)
mark_ok "$(printf '%-13s %d new tracks in beets' 'added today' "$added_total")"
if [[ "$added_total" -gt 0 ]]; then
# Per-playlist breakdown. Empty $grouping (bandcamp/manual) shown as "(none)".
echo "$ADDED_TODAY" \
| awk '{ if ($0 == "") print "(none)"; else print }' \
| sort | uniq -c | sort -rn \
| awk '{ g=$2; for(i=3;i<=NF;i++) g=g" "$i; printf " %3d %s\n", $1, g }'
fi
# 3. Maintenance freshness
# Each weekly/monthly task: find its newest log and check age.
# Limits: weekly tasks should be <9 days old; daily <2; monthly <35.
section "Maintenance (last run)"
declare -A MAINT_GLOB=(
[strip-mb-tags]="${ALEMBIC_CONFIG_DIR:-/config}/logs/mb-strip-*.log"
[strip-watermark-art]="${ALEMBIC_CONFIG_DIR:-/config}/logs/strip-watermark-*.log"
[scrub-watermark-text]="${ALEMBIC_CONFIG_DIR:-/config}/logs/scrub-text-*.log"
[clean-sldl-index]="${ALEMBIC_CONFIG_DIR:-/config}/logs/clean-index-*.log"
[clear-bad-genres]="${ALEMBIC_CONFIG_DIR:-/config}/logs/clear-bad-genres-*.log"
[spotify-genre]="${ALEMBIC_CONFIG_DIR:-/config}/logs/spotify-genre-*.log"
[dedup-library]="${ALEMBIC_CONFIG_DIR:-/config}/logs/dedup-*.log"
[upgrade-mp3-to-flac]="${ALEMBIC_CONFIG_DIR:-/config}/logs/upgrade-mp3-*.log"
)
declare -A MAINT_LIMIT=(
[strip-mb-tags]=$((9*86400))
[strip-watermark-art]=$((9*86400))
[scrub-watermark-text]=$((9*86400))
[clean-sldl-index]=$((9*86400))
[clear-bad-genres]=$((9*86400))
[spotify-genre]=$((9*86400))
[dedup-library]=$((2*86400))
[upgrade-mp3-to-flac]=$((35*86400))
)
for task in strip-mb-tags strip-watermark-art scrub-watermark-text clean-sldl-index clear-bad-genres spotify-genre dedup-library upgrade-mp3-to-flac; do
res=$(newest_log "${MAINT_GLOB[$task]}")
if [[ -z "$res" ]]; then
mark_skip "$(printf '%-22s never run yet' "$task")"
continue
fi
age_s=${res%%|*}
log=${res##*|}
age_h=$(human_age "$age_s")
case "$task" in
spotify-genre)
snip=$(grep -oE "written: [0-9]+, unchanged: [0-9]+" "$log" | tail -1) ;;
clear-bad-genres)
snip=$(grep -oE "(would-clear|cleared): [0-9]+ tracks" "$log" | tail -1) ;;
clean-sldl-index)
snip=$(grep -oE "[0-9]+ m3us, total [0-9]+ kept, [0-9]+ dropped" "$log" | tail -1) ;;
strip-watermark-art)
snip=$(grep -oE "stripped from [0-9]+ tracks|no images shared" "$log" | tail -1) ;;
scrub-watermark-text)
snip=$(grep -oE "stripped [0-9]+ frame\(s\) across [0-9]+ file" "$log" | tail -1) ;;
strip-mb-tags)
snip=$(grep -oE "Done\. Log:" "$log" | head -1 | sed 's/Done\. Log:/done/') ;;
dedup-library)
snip=$(grep "=== Summary:" "$log" | tail -1 \
| sed -E 's/.*=== Summary: //; s/ ===.*//; s/ \([^)]*\)//') ;;
upgrade-mp3-to-flac)
# The log's "to attempt" count is frozen at the last monthly run, so on
# its own it looks stale against the live "Library by format" section.
# Pair it with the current format:mp3 count so the drop (upgrades that
# have landed since) is visible instead of looking like a mismatch.
attempted=$(grep -oE "[0-9]+ MP3 tracks to attempt" "$log" | grep -oE "^[0-9]+" | tail -1)
mp3_now=$(beet ls 'format:mp3' 2>/dev/null | grep -c '^')
snip="${attempted:-?} attempted · ${mp3_now} MP3 now" ;;
esac
line=$(printf '%-22s %-9s %s' "$task" "$age_h" "${snip:-}")
if (( age_s > MAINT_LIMIT[$task] )); then
mark_warn "$line"
else
mark_ok "$line"
fi
done
# 4. Auth & state
section "Auth & state"
# Bandcamp cookie
if [[ -f ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt ]]; then
exp=$(awk -F'\t' '$6=="identity" {print $5; exit}' ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt)
if [[ -n "${exp:-}" && "$exp" =~ ^[0-9]+$ && "$exp" -gt 0 ]]; then
days=$(( (exp - NOW_TS) / 86400 ))
line=$(printf '%-22s %d days left' "Bandcamp cookie" "$days")
if (( days < 14 )); then mark_warn "$line — re-export soon"; else mark_ok "$line"; fi
else
mark_warn "Bandcamp cookie could not parse expiry"
fi
else
mark_warn "Bandcamp cookie missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt)"
fi
# Qobuz Web Player token (buy-link enrich cascade #2). The token is opaque
# (no decodable expiry), so probe it live: a cheap authed search returns 200
# while valid, 401 once it's logged out — at which point enrich-buy-url.py
# silently drops Qobuz and falls through to iTunes.
if [[ -f ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token ]]; then
q_token=$(tr -d '\r\n' < ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token)
q_appid=$(tr -d '\r\n' < ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/app_id 2>/dev/null)
q_appid=${q_appid:-798273057}
q_code=$(curl -s -o /dev/null --connect-timeout 5 --max-time 12 -A "Mozilla/5.0" \
-H "X-App-Id: $q_appid" -H "X-User-Auth-Token: $q_token" -w '%{http_code}' \
"https://www.qobuz.com/api.json/0.2/track/search?query=test&limit=1&app_id=$q_appid" 2>/dev/null)
case "$q_code" in
200) mark_ok "$(printf '%-22s %s' 'Qobuz token' 'valid (buy-link lookup live)')" ;;
401) mark_warn "$(printf '%-22s %s' 'Qobuz token' "EXPIRED — re-export X-User-Auth-Token to ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token")" ;;
*) mark_warn "$(printf '%-22s %s' 'Qobuz token' "check failed (HTTP ${q_code:-none})")" ;;
esac
else
mark_warn "$(printf '%-22s %s' 'Qobuz token' "missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token)")"
fi
# VPN egress
# alembic itself rides gluetun's network namespace (network_mode:
# service:gluetun), so its own outbound traffic IS gluetun's traffic —
# checking alembic's own egress IP directly is a more direct signal than
# asking Docker to introspect gluetun's health, and needs no Docker socket.
egress_ip=$(curl -s --connect-timeout 5 --max-time 10 https://ifconfig.me 2>/dev/null)
if [[ -n "$egress_ip" ]]; then
mark_ok "$(printf '%-22s %s' 'VPN egress' "$egress_ip")"
else
mark_warn "$(printf '%-22s %s' 'VPN egress' 'could not determine egress IP — VPN may be down')"
fi
# 5. Storage
section "Storage"
while read -r mp pct used total; do
line=$(printf '%-22s %s used (%s of %s)' "$mp" "$pct" "$used" "$total")
pct_n=${pct%\%}
if (( pct_n > 90 )); then mark_warn "$line"; else mark_ok "$line"; fi
done < <(df -h ${MUSIC_DATA_DIR:-/data/music} / 2>/dev/null | awk 'NR>1 && $1!~/tmpfs/ {print $6, $5, $3, $2}')
# 6. Navidrome
section "Navidrome"
ND_RESP=$(curl -s --connect-timeout 5 -G "$ND_BASE/rest/getScanStatus.view" \
--data-urlencode "u=$ND_USER" \
--data-urlencode "p=$ND_PASS" \
--data-urlencode 'v=1.16.0' --data-urlencode 'c=status' --data-urlencode 'f=json' 2>/dev/null)
ND_LINE=$(echo "$ND_RESP" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)['subsonic-response']
if d.get('status') == 'ok':
s = d.get('scanStatus', {})
cnt = s.get('count', '?')
cnt_str = f'{cnt:,}' if isinstance(cnt, int) else str(cnt)
print(f\"OK|last scan {s.get('lastScan','?')[:19]} · {cnt_str} items\")
else:
print(f\"WARN|status={d.get('status')}\")
except Exception as e:
print(f\"WARN|unreachable: {e}\")
" 2>/dev/null)
state=${ND_LINE%%|*}; detail=${ND_LINE##*|}
line=$(printf '%-22s %s' "Navidrome" "$detail")
if [[ "$state" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
# 7. Library by format (info-only, no status pill)
section "Library by format"
beet ls -f '$format' 2>/dev/null | sort | uniq -c | sort -rn \
| awk '{printf " %-6s %d tracks\n", $2, $1}'
} > "$OUT"
# ---- Build header banner and prepend ----
HOSTNAME_SHORT=$(hostname -s)
DATE_HUMAN=$(TZ=America/Edmonton date '+%a %Y-%m-%d %H:%M %Z')
BANNER_TITLE="🎵 Music pipeline · $HOSTNAME_SHORT · $DATE_HUMAN"
BANNER_TALLY=" $OK OK · $WARN warn · $SKIP skip"
sed -i "1c\\
${BANNER_TITLE}\\
${BANNER_TALLY}" "$OUT"
# Always mirror the one-line summary to syslog so journalctl shows last-run
# state even if Telegram is broken. Warnings get a separate WARN tag.
logger -t sldl-pipeline "status: $OK ok, $WARN warn, $SKIP skip (see $OUT)"
if (( WARN > 0 )); then
logger -t sldl-pipeline "WARN: pipeline-status flagged $WARN issue(s) — see $OUT"
fi
# Send to Telegram. If it fails, log the failure to syslog so the absence of
# a message in the chat has a corresponding journal entry to grep for.
if [[ -x ${PIPELINE_DIR:-/app/pipeline}/lib/notify-telegram.sh ]]; then
if ! ${PIPELINE_DIR:-/app/pipeline}/lib/notify-telegram.sh < "$OUT" 2>/tmp/tg-err; then
logger -t sldl-pipeline "WARN: Telegram send failed: $(cat /tmp/tg-err 2>/dev/null | head -c 200)"
rm -f /tmp/tg-err
fi
fi
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# ${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh
#
# Shared helper sourced by run-playlist.sh and import-track.sh.
# Pre-tags newly-arrived audio with ReplayGain (loudgain, track-mode) and
# Liquidsoap autocue tags (cue_file from Moonbase59/autocue) before beets
# ingests them, so AzuraCast sees fully-prepped files the moment they land
# in /music.
#
# Usage: prep_audio <scan_dir> <log_file>
# Returns: number of files processed (via stdout)
prep_audio() {
local scan_dir="$1"
local log="$2"
local files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$scan_dir" -type f \
\( -iname "*.flac" -o -iname "*.mp3" -o -iname "*.m4a" \
-o -iname "*.ogg" -o -iname "*.opus" \) -print0)
if [[ ${#files[@]} -eq 0 ]]; then
echo 0
return 0
fi
echo "[prep-audio] loudgain (track-mode RG, clip-safe) on ${#files[@]} file(s)" >>"$log"
# -q quiet, -k lower track gain to avoid clipping >-1 dBFS,
# -s e write extended ReplayGain2 + R128 tags, -I 4 ID3v2.4 for MP3.
# No -a, so each file gets its own track gain (track-mode).
if ! loudgain -q -k -s e -I 4 "${files[@]}" >>"$log" 2>&1; then
echo "[prep-audio] loudgain returned non-zero (see log)" >>"$log"
fi
echo "[prep-audio] cue_file (autocue liq_* tags) on ${#files[@]} file(s)" >>"$log"
# -w write tags into file, -k true-peak clipping prevention.
# Skips files that already have liq_cue_file=true (use -f to force).
local failed=0
for f in "${files[@]}"; do
if ! cue_file -w -k "$f" >>"$log" 2>&1; then
echo "[prep-audio] cue_file failed on $f" >>"$log"
failed=$((failed + 1))
fi
done
[[ $failed -gt 0 ]] && echo "[prep-audio] cue_file errors: $failed" >>"$log"
echo "${#files[@]}"
}
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""
recover-azuracast-playlists.py — recover playlist assignment for files that
lost their GROUPING tag (e.g. from a pre-tag-copy upgrade-mp3-to-flac.sh run).
For each AzuraCast file currently unassigned to any playlist:
1. Resolve the source playlist by matching (artist, title) against the
Spotify playlists named in $ALEMBIC_CONFIG_DIR/pipeline/*.conf.
2. Write GROUPING=<playlist> onto the FLAC/MP3 file.
3. `beet update` so beets sees the new tag.
4. Assign the file to the matching AzuraCast playlist via API.
Default is dry-run. Pass --apply to actually write tags + assign.
Env / args:
--azuracast-key KEY AzuraCast API key (required)
--azuracast-base URL default https://admin.ephemeral.club
--station N default 1
--apply actually act (default is dry-run)
--only PLAYLIST limit recovery to one playlist (testing)
"""
import argparse, base64, json, os, re, subprocess, sys, urllib.parse, urllib.request
from difflib import SequenceMatcher
from pathlib import Path
CONF_DIR = Path(f"{os.environ.get('ALEMBIC_CONFIG_DIR', '/config')}/pipeline")
LIB_ROOT_HOST = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library"
# Still "/music" through the migration's Stage 0-3 transitional beets mount;
# revisit at Stage 4 once beets' directory: becomes MUSIC_DATA_DIR/Library.
LIB_ROOT_CONTAINER = "/music"
def normalize(s: str) -> str:
s = (s or "").lower()
s = re.sub(r"\s*\([^)]*\)", "", s)
s = re.sub(r"\s*\[[^\]]*\]", "", s)
s = re.sub(r"\s*-?\s*(extended|original|radio|club|vip|vocal)\s*(mix|edit|version)\s*$", "", s)
s = re.sub(r"\s*feat\.?\s.*", "", s)
s = re.sub(r"\s*ft\.?\s.*", "", s)
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def read_conf(path: Path) -> dict:
out = {}
for line in path.read_text().splitlines():
m = re.match(r"\s*([a-z0-9_-]+)\s*=\s*(.*?)\s*$", line)
if m: out[m.group(1)] = m.group(2)
return out
def spotify_token(cid: str, csec: str) -> str:
creds = base64.b64encode(f"{cid}:{csec}".encode()).decode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=urllib.parse.urlencode({"grant_type": "client_credentials"}).encode(),
headers={"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())["access_token"]
def fetch_spotify_playlist(url: str, token: str) -> list[tuple[str, str]]:
"""Return list of (artists_normalized_joined, title_normalized)."""
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", url)
if not m: return []
pid = m.group(1)
out = []
next_url = f"https://api.spotify.com/v1/playlists/{pid}/tracks?limit=100"
while next_url:
req = urllib.request.Request(next_url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read())
for item in data["items"]:
t = item.get("track")
if not t or t.get("is_local"): continue
artists = [a["name"] for a in t["artists"]]
out.append((artists, t["name"]))
next_url = data.get("next")
return out
def beets_dump() -> dict:
"""Return path → {grouping, artist, albumartist, album, title}"""
proc = subprocess.run(
["beet", "ls", "-f",
"$path\t$grouping\t$artist\t$albumartist\t$album\t$title"],
capture_output=True, text=True, check=True)
out = {}
for line in proc.stdout.splitlines():
parts = line.split("\t")
if len(parts) < 6: continue
out[parts[0]] = {
"grouping": parts[1],
"artist": parts[2],
"albumartist": parts[3],
"album": parts[4],
"title": parts[5],
}
return out
def az_request(base: str, key: str, path: str, method="GET", body=None) -> dict:
url = f"{base}{path}"
headers = {"X-API-Key": key, "Accept": "application/json"}
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def az_all_files(base: str, key: str, station: int) -> list[dict]:
out = []
page = 1
while True:
d = az_request(base, key, f"/api/station/{station}/files?per_page=500&page={page}")
out.extend(d["rows"])
if page >= d["total_pages"]: break
page += 1
return out
def az_playlists_by_name(base: str, key: str, station: int) -> dict:
return {p["name"]: p["id"]
for p in az_request(base, key, f"/api/station/{station}/playlists")}
def write_grouping(host_path: str, grouping: str) -> bool:
"""Write GROUPING tag in-place. Return True on success."""
ext = host_path.rsplit(".", 1)[-1].lower()
if ext == "flac":
subprocess.run(["metaflac", "--remove-tag=GROUPING", host_path],
capture_output=True, check=False)
return subprocess.run(["metaflac", f"--set-tag=GROUPING={grouping}", host_path],
capture_output=True).returncode == 0
if ext == "mp3":
return subprocess.run(["id3v2", "--TIT1", grouping, host_path],
capture_output=True).returncode == 0
return False
def beet_update(container_paths: list[str], log) -> None:
"""Re-read tags from disk into beets DB for the given container paths.
Uses `path:<exact>` query (not `path::<regex>`) and pipes "y" since
beet update prompts interactively for each change."""
if not container_paths: return
for cp in container_paths:
subprocess.run(
["beet", "update", "--nomove", f"path:{cp}"],
input="y\n", capture_output=True, text=True, check=False)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--azuracast-key", required=True)
ap.add_argument("--azuracast-base", default="https://admin.ephemeral.club")
ap.add_argument("--station", type=int, default=1)
ap.add_argument("--apply", action="store_true")
ap.add_argument("--only", default=None, help="limit recovery to one playlist name")
args = ap.parse_args()
print("[1/5] Loading beets metadata...", flush=True)
beets = beets_dump()
print(f" {len(beets)} tracks in beets")
print("[2/5] Fetching Spotify playlist contents...", flush=True)
# Build (title_norm) → list of (playlist_name, artists_list)
title_idx: dict[str, list[tuple[str, list[str]]]] = {}
confs = sorted(CONF_DIR.glob("*.conf"))
confs = [c for c in confs if not c.name.startswith("_")]
if not confs:
print(f"ERROR: no *.conf files in {CONF_DIR}/", file=sys.stderr); return 1
first = read_conf(confs[0])
token = spotify_token(first["spotify-id"], first["spotify-secret"])
pl_count = 0
for c in confs:
cfg = read_conf(c)
url = cfg.get("input"); pname = c.stem
if not url or not url.startswith("https://open.spotify.com/"): continue
try:
tracks = fetch_spotify_playlist(url, token)
except Exception as e:
print(f" WARN: {pname}: {e}"); continue
for artists, title in tracks:
title_idx.setdefault(normalize(title), []).append((pname, artists))
pl_count += 1
print(f" {pname}: {len(tracks)} tracks")
print(f" indexed {pl_count} playlists, {sum(len(v) for v in title_idx.values())} title→playlist entries")
print("[3/5] Fetching AzuraCast playlists + files...", flush=True)
playlists_by_name = az_playlists_by_name(args.azuracast_base, args.azuracast_key, args.station)
print(f" {len(playlists_by_name)} AzuraCast playlists")
all_files = az_all_files(args.azuracast_base, args.azuracast_key, args.station)
unassigned = [r for r in all_files if not r.get("playlists")]
print(f" {len(all_files)} total files, {len(unassigned)} unassigned")
print("[4/5] Matching unassigned files to playlists...", flush=True)
matches: list[dict] = [] # rows with .target_playlist
unmatched: list[dict] = []
multi_match: list[dict] = [] # ambiguous
no_az_playlist: list[dict] = []
for r in unassigned:
cp = f"{LIB_ROOT_CONTAINER}/{r['path']}"
bmeta = beets.get(cp, {})
# 1) trust beets GROUPING if present
target = bmeta.get("grouping", "").strip() if bmeta else ""
source = "beets_grouping" if target else None
if not target:
# 2) resolve via Spotify (artist, title) match
title_n = normalize(bmeta.get("title") or r.get("title") or "")
file_artists = bmeta.get("artist") or r.get("artist") or ""
candidates = title_idx.get(title_n, [])
if not candidates:
unmatched.append({"id": r["id"], "path": r["path"], "reason": "no spotify title hit"})
continue
# Score candidates by artist overlap
file_artist_n = normalize(file_artists)
scored = []
for pname, artists in candidates:
joined_n = normalize("; ".join(artists))
score = SequenceMatcher(None, file_artist_n, joined_n).ratio()
scored.append((score, pname))
scored.sort(reverse=True)
top_score, top_pname = scored[0]
if top_score < 0.4:
unmatched.append({"id": r["id"], "path": r["path"], "reason": f"low artist score {top_score:.2f}"})
continue
# If second-best is within 0.05, it's ambiguous between playlists
distinct_playlists = {p for _, p in scored}
if len(distinct_playlists) > 1 and len(scored) > 1 and scored[1][0] > top_score - 0.05 and scored[1][1] != top_pname:
multi_match.append({"id": r["id"], "path": r["path"], "candidates": [p for _, p in scored[:3]]})
continue
target = top_pname
source = "spotify_match"
if args.only and target != args.only:
continue
if target not in playlists_by_name:
no_az_playlist.append({"id": r["id"], "path": r["path"], "target": target})
continue
matches.append({
"id": r["id"], "path": r["path"], "target": target, "source": source,
"host_path": f"{LIB_ROOT_HOST}/{r['path']}",
"container_path": cp,
"playlist_id": playlists_by_name[target],
})
# Report
by_target: dict[str, int] = {}
for m in matches:
by_target[m["target"]] = by_target.get(m["target"], 0) + 1
print()
print(f"=== Match summary ===")
print(f" matched: {len(matches)}")
for pname, n in sorted(by_target.items(), key=lambda x: -x[1]):
print(f" {pname}: {n}")
print(f" unmatched (no spotify hit): {len(unmatched)}")
print(f" ambiguous (multiple playlists tied): {len(multi_match)}")
print(f" no matching AzuraCast playlist: {len(no_az_playlist)}")
if unmatched[:5]:
print(" unmatched sample:")
for u in unmatched[:5]:
print(f" {u['path']} ({u['reason']})")
if multi_match[:5]:
print(" ambiguous sample:")
for u in multi_match[:5]:
print(f" {u['path']} candidates={u['candidates']}")
if not args.apply:
print()
print("DRY RUN — pass --apply to (a) write GROUPING tags, (b) beet update, (c) assign in AzuraCast")
return 0
print()
print("[5/5] Applying...", flush=True)
tag_ok = 0; tag_fail = 0
az_ok = 0; az_fail = 0
for m in matches:
# Write tag
if write_grouping(m["host_path"], m["target"]):
tag_ok += 1
else:
tag_fail += 1
print(f" TAG-FAIL {m['host_path']}")
# Assign in AzuraCast: PUT /api/station/N/file/{id} with playlists field.
# Send ONLY the playlists key — sending the full record back causes
# AzuraCast to reject with HTTP 500 (validation fails on derived fields).
try:
az_request(args.azuracast_base, args.azuracast_key,
f"/api/station/{args.station}/file/{m['id']}",
method="PUT", body={"playlists": [{"id": m["playlist_id"]}]})
az_ok += 1
except Exception as e:
az_fail += 1
print(f" AZ-FAIL {m['path']}: {e}")
print(f" tags written: {tag_ok} (failed: {tag_fail})")
print(f" azuracast assigned: {az_ok} (failed: {az_fail})")
# beet update so beets DB sees the new GROUPING
print(" running beet update on changed paths...")
beet_update([m["container_path"] for m in matches], None)
print(" done")
return 0
if __name__ == "__main__":
sys.exit(main())
+243
View File
@@ -0,0 +1,243 @@
#!/bin/bash
# Walk a "stranded" download directory (files beets rejected as duplicates),
# and for each file, compare against the existing library entry. If the new
# file is higher quality (FLAC > MP3, then larger size), REPLACE the library
# version. Otherwise delete the new file.
#
# Usage:
# replace-with-better.sh # default scan: ${MUSIC_DATA_DIR:-/data/music}/downloads
# replace-with-better.sh <scan_dir> # custom scan dir
# replace-with-better.sh --apply # actually replace (default is dry-run)
#
# Run after sync-bandcamp.sh when the FLAC > MP3 dedup didn't pick up new
# imports (beets quietly skipped them and left files in /downloads).
set -u
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh
source ${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh
APPLY=0
COPY_TAGS=0
SCAN_DIR=${MUSIC_DATA_DIR:-/data/music}/downloads
for arg in "$@"; do
case "$arg" in
--apply) APPLY=1 ;;
--copy-tags-from-existing) COPY_TAGS=1 ;;
*) SCAN_DIR="$arg" ;;
esac
done
# Copy canonical tags from an existing (already spotify-retagged) library track
# onto a new FLAC, replacing whatever uploader-supplied tags it came with.
# Uses beets as the source of truth — same fields beets/Navidrome care about.
copy_tags_from_existing() {
local existing_id="$1" # beets item id of the existing library track
local new_file="$2" # host path of new FLAC
[[ "${new_file,,}" == *.flac ]] || return 0
# \x1f separator, NOT \t: tab is IFS whitespace, so consecutive tabs
# (an empty field like $genres) collapse and shift every later field left —
# GROUPING landing in GENRE etc. \x1f is non-whitespace, so empty fields
# survive the read. Query by id: path queries are unreliable against the
# mixed absolute/relative storage the beets 2.11 upgrade left in the DB.
local raw
raw=$(beet ls -f \
$'$artist\x1f$albumartist\x1f$album\x1f$title\x1f$track\x1f$disc\x1f$year\x1f$genres\x1f$grouping' \
"id:${existing_id}" 2>/dev/null | head -1)
[[ -z "$raw" ]] && return 0
local artist albumartist album title track disc year genre grouping
IFS=$'\x1f' read -r artist albumartist album title track disc year genre grouping <<< "$raw"
local tag
for tag in ARTIST ALBUMARTIST ALBUM TITLE TRACKNUMBER DISCNUMBER DATE GENRE GROUPING; do
metaflac --remove-tag="$tag" "$new_file" 2>/dev/null
done
[[ -n "$artist" ]] && metaflac --set-tag="ARTIST=$artist" "$new_file" 2>/dev/null
[[ -n "$albumartist" ]] && metaflac --set-tag="ALBUMARTIST=$albumartist" "$new_file" 2>/dev/null
[[ -n "$album" ]] && metaflac --set-tag="ALBUM=$album" "$new_file" 2>/dev/null
[[ -n "$title" ]] && metaflac --set-tag="TITLE=$title" "$new_file" 2>/dev/null
[[ -n "$track" && "$track" != "0" ]] && metaflac --set-tag="TRACKNUMBER=$track" "$new_file" 2>/dev/null
[[ -n "$disc" && "$disc" != "0" ]] && metaflac --set-tag="DISCNUMBER=$disc" "$new_file" 2>/dev/null
[[ -n "$year" && "$year" != "0" ]] && metaflac --set-tag="DATE=$year" "$new_file" 2>/dev/null
[[ -n "$genre" ]] && metaflac --set-tag="GENRE=$genre" "$new_file" 2>/dev/null
[[ -n "$grouping" ]] && metaflac --set-tag="GROUPING=$grouping" "$new_file" 2>/dev/null
}
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/replace-$(date +%Y%m%d-%H%M%S).log
mkdir -p "$(dirname "$LOG")"
if [[ $APPLY -eq 1 ]]; then
echo "[$(date -Iseconds)] === REPLACE (APPLY) scan=$SCAN_DIR ===" | tee -a "$LOG"
# Radio prep (ReplayGain + autocue liq_* tags) BEFORE any import. Every
# other import path preps first (run-playlist.sh, import-track.sh); this
# one didn't, so replacement FLACs and leftover imports reached the library
# unprepped for AzuraCast (found 2026-07-02: the July upgrade FLACs and a
# 39-track leftover batch all lacked RG/autocue). Apply-mode only — dry
# runs must not modify files.
PREPPED=$(prep_audio "$SCAN_DIR" "$LOG" || echo 0)
echo "[prep-audio] pre-tagged $PREPPED file(s) with ReplayGain + autocue" | tee -a "$LOG"
else
echo "[$(date -Iseconds)] === REPLACE (DRY RUN — pass --apply to act) scan=$SCAN_DIR ===" | tee -a "$LOG"
fi
# A FLAC file always beats an MP3 file regardless of size. Among same-format
# files, larger wins. Returns "new" or "existing".
better() {
local new="$1" existing="$2"
local new_flac=0 ex_flac=0
[[ "${new,,}" == *.flac ]] && new_flac=1
[[ "${existing,,}" == *.flac ]] && ex_flac=1
if [[ $new_flac -ne $ex_flac ]]; then
if [[ $new_flac -eq 1 ]]; then echo "new"; else echo "existing"; fi
return
fi
local ns es
ns=$(stat -c '%s' "$new" 2>/dev/null || echo 0)
es=$(stat -c '%s' "$existing" 2>/dev/null || echo 0)
if [[ $ns -gt $es ]]; then echo "new"; else echo "existing"; fi
}
# Read tag from FLAC or MP3 — return artist/album/title joined with newlines.
read_tags() {
local f="$1"
case "${f,,}" in
*.flac)
local artist album title
artist=$(metaflac --show-tag=ALBUMARTIST "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
[[ -z "$artist" ]] && artist=$(metaflac --show-tag=ARTIST "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
album=$(metaflac --show-tag=ALBUM "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
title=$(metaflac --show-tag=TITLE "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
printf "%s\n%s\n%s\n" "$artist" "$album" "$title"
;;
*.mp3)
local artist album title
artist=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TPE2[^:]*: //p' | head -1)
[[ -z "$artist" ]] && artist=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TPE1[^:]*: //p' | head -1)
album=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TALB[^:]*: //p' | head -1)
title=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TIT2[^:]*: //p' | head -1)
printf "%s\n%s\n%s\n" "$artist" "$album" "$title"
;;
esac
}
# Beets queries (case-insensitive exact-substring on a field). Try progressively
# looser matches until we find exactly one library track to compare against.
# Echoes "<id>\x1f<path>" of the unique match; "" if no unique match found.
beet_find_match() {
local artist="$1" album="$2" title="$3"
# Sanitize quotes
artist="${artist//\"/}"; album="${album//\"/}"; title="${title//\"/}"
local fmt=$'$id\x1f$path'
local hits
# 1. Strictest: albumartist + album + title
hits=$(beet ls -f "$fmt" \
"albumartist:$artist" "album:$album" "title:$title" 2>/dev/null)
[[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; }
# 2. Drop album: maybe Bandcamp's "Artist | Album" doesn't match library's "Album"
hits=$(beet ls -f "$fmt" \
"albumartist:$artist" "title:$title" 2>/dev/null)
[[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; }
# 3. albumartist may be empty in old library entries — fall back to artist field
hits=$(beet ls -f "$fmt" \
"artist:$artist" "title:$title" 2>/dev/null)
[[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; }
# 4. Last resort: title alone — anchored exact match (case-insensitive),
# NOT the substring match levels 1-3 use: a new track titled "Home"
# must not "uniquely" match a library track titled "Coming Home Tonight"
# and get it deleted. Only safe if exactly one library track carries
# exactly this title.
local escaped_title
escaped_title=$(python3 -c \
"import re,sys; print(re.escape(sys.argv[1]))" "$title" 2>/dev/null || echo "")
if [[ -n "$escaped_title" ]]; then
hits=$(beet ls -f "$fmt" \
"title::(?i)^${escaped_title}\$" 2>/dev/null)
[[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; }
fi
echo "" # no unique match
}
REPLACED=0
KEPT=0
SKIPPED_NOMATCH=0
while IFS= read -r -d '' new_file; do
# mapfile preserves empty lines (read with IFS=$'\n' would collapse them)
mapfile -t _fields < <(read_tags "$new_file")
artist="${_fields[0]:-}"
album="${_fields[1]:-}"
title="${_fields[2]:-}"
if [[ -z "$artist" || -z "$title" ]]; then
echo "[skip-untagged] $new_file" | tee -a "$LOG"
continue
fi
# Find the matching library track via beets (progressive loosening)
match=$(beet_find_match "$artist" "$album" "$title" | head -1)
if [[ -z "$match" || "$match" != *$'\x1f'* ]]; then
echo "[no-match] $artist - $album - $title (file: $new_file)" | tee -a "$LOG"
SKIPPED_NOMATCH=$((SKIPPED_NOMATCH + 1))
continue
fi
existing_id="${match%%$'\x1f'*}"
existing_container="${match#*$'\x1f'}"
existing="${MUSIC_DATA_DIR:-/data/music}/Library${existing_container#/music}"
if [[ ! -f "$existing" ]]; then
echo "[stale-db] beets has $existing_container but file missing" | tee -a "$LOG"
continue
fi
winner=$(better "$new_file" "$existing")
ns=$(numfmt --to=iec --suffix=B "$(stat -c '%s' "$new_file" 2>/dev/null || echo 0)")
es=$(numfmt --to=iec --suffix=B "$(stat -c '%s' "$existing" 2>/dev/null || echo 0)")
if [[ "$winner" == "new" ]]; then
echo "[REPLACE] $artist - $title ($es$ns)" | tee -a "$LOG"
echo " ex: $existing" | tee -a "$LOG"
echo " new: $new_file" | tee -a "$LOG"
if [[ $APPLY -eq 1 ]]; then
# If asked, copy canonical tags from the existing track to the new FLAC
# BEFORE removing the existing entry. Used by upgrade-mp3-to-flac.sh so
# the new FLAC inherits the MP3's spotify-retagged metadata (incl. the
# GROUPING tag that drives playlist M3U regeneration).
if [[ $COPY_TAGS -eq 1 ]]; then
copy_tags_from_existing "$existing_id" "$new_file" >> "$LOG" 2>&1
fi
# Remove old library file + beets DB entry, then re-import the new one.
# id query, not path: — see copy_tags_from_existing for why.
beet remove -d -f "id:$existing_id" >> "$LOG" 2>&1
beet import -q -s "$new_file" >> "$LOG" 2>&1
fi
REPLACED=$((REPLACED + 1))
else
echo "[KEEP] $artist - $title (existing $es ≥ new $ns)" | tee -a "$LOG"
if [[ $APPLY -eq 1 ]]; then
rm -f "$new_file"
fi
KEPT=$((KEPT + 1))
fi
done < <(find "$SCAN_DIR" -type f \( -iname "*.flac" -o -iname "*.mp3" \) -print0 2>/dev/null)
echo "" | tee -a "$LOG"
echo "=== Summary: $REPLACED replaced, $KEPT existing-kept, $SKIPPED_NOMATCH no-match ===" | tee -a "$LOG"
[[ $APPLY -eq 0 ]] && echo "DRY RUN — re-run with --apply to act" | tee -a "$LOG"
# After replaces, anything left in the scan dir is genuinely NEW (no library
# match — not a duplicate of anything we already have). Import it as a new
# track. Beets path templates will place it under /music/<albumartist>/...
# alongside everything else.
if [[ $APPLY -eq 1 ]]; then
remaining=$(find "$SCAN_DIR" -type f \( -iname "*.flac" -o -iname "*.mp3" \) 2>/dev/null | wc -l)
if [[ "$remaining" -gt 0 ]]; then
echo "[import-leftover] $remaining new file(s) under $SCAN_DIR — importing as fresh tracks" | tee -a "$LOG"
beet import -q -s "$SCAN_DIR" >> "$LOG" 2>&1
fi
find "$SCAN_DIR" -mindepth 1 -type d -empty -delete 2>>"$LOG"
echo "[cleanup] removed empty subfolders under $SCAN_DIR" | tee -a "$LOG"
fi
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""
scrub-watermark-text.py — strip Soulseek-uploader watermark URLs from
metadata text frames across the library. Some uploaders pollute every
available field (COMM, USLT, TXXX, WXXX, even TCON/genre) with their
domain, so a track might have 8+ watermark frames each pointing at
electronicfresh.com / djsoundtop.com / etc.
Targets — for any frame that contains a URL or known watermark domain:
MP3 (ID3v2):
COMM: comments
USLT: unsynchronized lyrics
TXXX: user-defined text (kept if value is non-URL)
WXXX, WCOM, WPAY, WPUB, WORS, WCOP: URL link frames (always stripped)
TCOP: copyright
TPUB: publisher
TENC: encoded by
TCON: genre (only stripped if it's a URL — real genres are short text)
FLAC (Vorbis):
COMMENT, DESCRIPTION, COPYRIGHT, ENCODED-BY, CONTACT, WEBPAGE,
LICENSE — wiped if value contains URL pattern
GENRE — wiped only if it looks like a URL
Frames that hold real metadata we always keep:
TIT2/TIT1/TPE1/TPE2/TALB/TPOS/TRCK/TDRC/TYER/TXXX:GROUPING (ours)
ARTIST/ALBUMARTIST/ALBUM/TITLE/TRACKNUMBER/DISCNUMBER/DATE/GROUPING
Usage:
scrub-watermark-text.py # dry run
scrub-watermark-text.py --apply # actually strip
"""
import sys, re, argparse
from pathlib import Path
from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError
from mutagen.flac import FLAC
LIBRARY = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library"
# Anything containing one of these is suspect. Add to taste.
WATERMARK_DOMAINS = [
"electronicfresh", "djsoundtop", "iptorrents", "torrentday",
"ftpdjemilio", "hypeddit", "hypeddit.com", "djseamusic", "djpool",
"myzuka", "promodj",
]
# Generic URL pattern — http(s)://, www., or a bare domain.tld
URL_RE = re.compile(
r"(?ix)"
r"(?:https?://|www\.|[a-z0-9-]+\.(?:com|net|org|io|to|cc|me|tv|fm|gg|co|sh|ru))"
r"[a-z0-9./?#=&_+:%-]*"
)
# Frames that should be FULLY removed if they contain ANY URL.
# NOTE: WCOM ("commercial information") and WPAY ("payment") are deliberately
# NOT stripped — that's where the buy-link pipeline stores the legit Bandcamp
# purchase URL (see sync-bandcamp.py BUY_URL_TAG / README "Buy links"). On
# FLAC the equivalent COMMERCIAL_INFORMATION Vorbis comment is already safe
# (it isn't in FLAC_TEXT_FIELDS_TO_CHECK). Watermarkers overwhelmingly abuse
# COMM/TXXX/WXXX/USLT, not WCOM/WPAY, so excluding these costs us nothing.
MP3_URL_LINK_FRAMES = ("WXXX", "WPUB", "WORS", "WCOP", "WOAR", "WOAS", "WOAF")
# Frames where we strip the WHOLE frame if its text content contains a URL.
MP3_TEXT_FRAMES_TO_CHECK = ("COMM", "USLT", "TCOP", "TPUB", "TENC", "TXXX",
"WXXX", "TOPE", "TOAL")
# TCON is special — only strip if it's a URL (real genres are normal text)
def text_has_url(s):
if not s: return False
s = str(s)
if URL_RE.search(s):
return True
sl = s.lower()
return any(d in sl for d in WATERMARK_DOMAINS)
def scrub_mp3(path, apply):
try:
tags = ID3(path)
except (ID3NoHeaderError, Exception):
return 0, []
to_delete = []
actions = []
for k in list(tags.keys()):
prefix = k[:4]
# URL link frames — strip if they have anything URL-y
if prefix in MP3_URL_LINK_FRAMES:
v = getattr(tags[k], "url", None) or str(tags[k])
if text_has_url(v):
to_delete.append(k)
actions.append(f"remove {k}: {str(v)[:80]}")
continue
# Text frames — check the rendered text
if prefix in MP3_TEXT_FRAMES_TO_CHECK:
v = str(tags[k])
if text_has_url(v):
to_delete.append(k)
actions.append(f"remove {k}: {v[:80]}")
continue
# TCON: genre. Strip if it's clearly a URL.
if prefix == "TCON":
v = str(tags[k])
if text_has_url(v):
to_delete.append(k)
actions.append(f"remove TCON (was URL'd genre): {v[:80]}")
continue
if apply and to_delete:
for k in to_delete:
del tags[k]
try:
tags.save(path)
except Exception as e:
actions.append(f" SAVE FAILED: {e}")
return len(to_delete), actions
# Vorbis comment fields (case-insensitive in spec, we handle that)
FLAC_TEXT_FIELDS_TO_CHECK = (
"comment", "description", "copyright", "encoded-by",
"contact", "webpage", "website", "license", "url",
)
def scrub_flac(path, apply):
try:
f = FLAC(path)
except Exception:
return 0, []
to_delete = [] # list of (field_name, value)
actions = []
for key in list(f.tags.keys() if f.tags else []):
kl = key.lower()
vals = f.tags[key]
if kl in FLAC_TEXT_FIELDS_TO_CHECK:
for v in vals:
if text_has_url(v):
to_delete.append((key, v))
actions.append(f"remove {key}: {v[:80]}")
elif kl == "genre":
for v in vals:
if text_has_url(v):
to_delete.append((key, v))
actions.append(f"remove genre (was URL'd): {v[:80]}")
if apply and to_delete:
# Group by field, rebuild the field's value list without URL-y entries.
fields_to_rewrite = {}
for key, val in to_delete:
fields_to_rewrite.setdefault(key, []).append(val)
for key, bad_vals in fields_to_rewrite.items():
current = list(f.tags[key])
keep = [v for v in current if v not in bad_vals]
if keep:
f.tags[key] = keep
else:
del f.tags[key]
try:
f.save()
except Exception as e:
actions.append(f" SAVE FAILED: {e}")
return len(to_delete), actions
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
print(f"[scrub-text] mode={'APPLY' if args.apply else 'DRY RUN'}")
print(f"[scrub-text] watermark domains: {WATERMARK_DOMAINS}\n")
files = list(Path(LIBRARY).rglob("*.mp3")) + list(Path(LIBRARY).rglob("*.flac"))
print(f"[scrub-text] scanning {len(files)} files...\n")
total_frames = 0
files_touched = 0
for i, p in enumerate(files, 1):
if i % 200 == 0:
print(f" ...{i}/{len(files)}", flush=True)
sfx = p.suffix.lower()
if sfx == ".mp3":
n, actions = scrub_mp3(str(p), args.apply)
elif sfx == ".flac":
n, actions = scrub_flac(str(p), args.apply)
else:
continue
if n > 0:
files_touched += 1
total_frames += n
print(f"\n {p.name} ({n} frame(s))")
for a in actions[:5]:
print(f" {a}")
if len(actions) > 5:
print(f" ...and {len(actions) - 5} more")
verb = "stripped" if args.apply else "would-strip"
print(f"\n[scrub-text] {verb} {total_frames} frame(s) across {files_touched} file(s)")
if not args.apply:
print("[scrub-text] re-run with --apply to write changes")
if __name__ == "__main__":
sys.exit(main())
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
spotify-genre.py — write GENRE tags onto every audio file in the library
using Spotify's per-artist genre data, filtered against a whitelist.
Spotify only has genre at the ARTIST level, but their taxonomy is curated
and clean (e.g. "tropical house", "tech house", "synthwave"). We match
each artist's reported genres against our whitelist to pick up to 3
canonical display strings, then write to every track by that artist.
Whitelist matching is fuzzy-normalized: lowercased, dashes/spaces stripped.
So Spotify's "tropical house" matches our whitelist's "Tropical House"
(or rolls into "House" if Tropical House isn't in the list).
Per-artist caching: each unique artist is looked up once, even if they
have 100 tracks in the library.
Falls back gracefully:
- Artist not on Spotify -> no change
- Found but no whitelist match -> no change
Usage:
spotify-genre.py # dry run, show plan
spotify-genre.py --apply # write tags
spotify-genre.py --apply --force # overwrite existing GENRE tags
spotify-genre.py --apply --query '...' # subset by beets query
"""
import sys, os, re, json, argparse, base64, urllib.parse, urllib.request, subprocess
from pathlib import Path
from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError, TCON
from mutagen.flac import FLAC
from mutagen.mp4 import MP4
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
# OGG/OPUS use Vorbis-comment-style tags (lowercase keys, multi-value), same
# shape as FLAC. Treat them as a family so the read/write paths are shared.
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
def _load_spotify_creds():
"""Spotify creds, rendered by alembic's credential_service to _spotify.env."""
path = f"{ALEMBIC_CONFIG_DIR}/pipeline/_spotify.env"
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v.strip().strip("'").strip('"')
return env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"]
SPOTIFY_CID, SPOTIFY_CSEC = _load_spotify_creds()
WHITELIST_FILE = f"{ALEMBIC_CONFIG_DIR}/pipeline/genres-whitelist.txt"
MAX_GENRES = 3
SEPARATOR = "; "
def load_whitelist():
"""Load whitelist and build a normalized→display mapping for fuzzy matching."""
out = {}
with open(WHITELIST_FILE) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
normalized = re.sub(r"[\s\-_&/.]+", "", line).lower()
out[normalized] = line
return out
_token = None
def _spotify_token():
global _token
if _token: return _token
creds = base64.b64encode(f"{SPOTIFY_CID}:{SPOTIFY_CSEC}".encode()).decode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=urllib.parse.urlencode({"grant_type":"client_credentials"}).encode(),
headers={"Authorization":f"Basic {creds}",
"Content-Type":"application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=15) as r:
_token = json.loads(r.read())["access_token"]
return _token
# Cache: artist_name → list[str] of Spotify genres (or None if not found)
_artist_genre_cache = {}
def spotify_artist_genres(artist_name):
"""Return Spotify's genre list for the artist, or None if not found.
Cached per-artist so 1500 tracks for ~300 artists = ~300 API calls."""
if artist_name in _artist_genre_cache:
return _artist_genre_cache[artist_name]
# Search artist by name (limit=1, take best match)
qs = urllib.parse.urlencode({
"q": f'artist:"{artist_name}"',
"type": "artist",
"limit": 1,
})
url = f"https://api.spotify.com/v1/search?{qs}"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {_spotify_token()}"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
data = json.loads(r.read())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, TimeoutError):
_artist_genre_cache[artist_name] = None
return None
items = data.get("artists", {}).get("items", [])
if not items:
_artist_genre_cache[artist_name] = None
return None
# Validate: Spotify's fuzzy search will silently match short/ambiguous
# names to the wrong artist (e.g. "G Jones" → "George Jones" the country
# singer, "T78" → some random T78). Reject the result unless the returned
# name matches what we asked for after normalizing whitespace/punct.
def _norm(s):
return re.sub(r"[\s\-_&/.()\[\]'\"]+", "", s or "").lower()
requested = _norm(artist_name)
returned = _norm(items[0].get("name", ""))
if requested and returned and requested != returned:
_artist_genre_cache[artist_name] = None
return None
genres = items[0].get("genres") or []
_artist_genre_cache[artist_name] = genres
return genres
# Map Spotify-style spellings to the normalized form of their whitelist entry.
# Spotify uses "drum and bass" / "rock and roll" / "r&b" — our whitelist uses
# "Drum & Bass" / "Rock & Roll" / "R&B". The "and"/"&" difference prevents the
# roll-up match below, so we explicitly resolve common variants first.
ALIASES = {
"drumandbass": "drumbass",
"rockandroll": "rockroll",
"randb": "rb",
"rnb": "rb",
"hiphop": "hiphop",
"ukgarage": "ukgarage",
}
def match_whitelist(genres, whitelist):
"""Pick up to MAX_GENRES whitelist entries from Spotify's genre list,
in Spotify's reported order (most-relevant first). Returns list of
display strings.
For specific genres like 'tropical house' that aren't in the whitelist,
we also check if any whitelist entry's normalized form is contained
within the genre's normalized form — so 'tropical house' rolls up to
'House' if Tropical House isn't a separate whitelist entry."""
out = []
seen = set()
for g in genres or []:
norm = re.sub(r"[\s\-_&/.]+", "", g).lower()
# Resolve known aliases (e.g. "drum and bass" → "drumbass") before lookup
norm = ALIASES.get(norm, norm)
# Direct hit
display = whitelist.get(norm)
if display:
if display not in seen:
out.append(display); seen.add(display)
continue
# Roll-up: e.g. "tropicalhouse" contains "house"
for wl_norm, wl_display in whitelist.items():
if wl_norm and wl_norm in norm and len(wl_norm) >= 4:
if wl_display not in seen:
out.append(wl_display); seen.add(wl_display)
break
if len(out) >= MAX_GENRES:
break
return out[:MAX_GENRES]
def is_genre_locked(filepath):
"""Return True if the file has GENRE_LOCK=1, meaning manual genre should not be overwritten."""
try:
m = MFile(filepath)
except Exception:
return False
if not m or not m.tags:
return False
try:
if isinstance(m, VORBIS_LIKE):
val = (m.tags.get("genre_lock") or m.tags.get("GENRE_LOCK") or [None])[0]
return val == "1"
else:
# MP3: check TXXX:GENRE_LOCK
t = getattr(m, "tags", None)
if t is not None:
for frame in t.getall("TXXX"):
if frame.desc.upper() == "GENRE_LOCK" and frame.text and frame.text[0] == "1":
return True
except (AttributeError, IndexError, TypeError):
pass
return False
def get_track_meta(filepath):
"""Read (artist, title, current_genre) from the file. Returns (None,None,None) on failure."""
try:
m = MFile(filepath)
except Exception:
return None, None, None
if not m or not m.tags:
return None, None, None
artist = title = genre = None
try:
if isinstance(m, VORBIS_LIKE):
# FLAC/OGG/OPUS share Vorbis-comment-style tags
artist = (m.tags.get("artist") or [None])[0]
title = (m.tags.get("title") or [None])[0]
genre = (m.tags.get("genre") or [None])[0]
elif isinstance(m, MP4):
artist = (m.tags.get("©ART") or [None])[0]
title = (m.tags.get("©nam") or [None])[0]
genre = (m.tags.get("©gen") or [None])[0]
else:
# MP3 ID3v2 — read via .text[0] of the frame to avoid str() returning frame metadata
t = getattr(m, "tags", None)
if t is not None:
if "TPE1" in t and t["TPE1"].text: artist = str(t["TPE1"].text[0])
if "TIT2" in t and t["TIT2"].text: title = str(t["TIT2"].text[0])
if "TCON" in t and t["TCON"].text: genre = str(t["TCON"].text[0])
except (AttributeError, IndexError, TypeError):
pass
return artist, title, genre
def write_genre(filepath, value):
"""Write GENRE tag on the file."""
try:
m = MFile(filepath)
except Exception:
return False
if isinstance(m, VORBIS_LIKE):
m.tags["GENRE"] = value # mutagen Vorbis-style tags are case-insensitive
m.save()
elif isinstance(m, MP4):
m.tags["©gen"] = value
m.save()
else:
try:
tags = ID3(filepath)
except ID3NoHeaderError:
tags = ID3()
tags.delall("TCON")
tags.add(TCON(encoding=3, text=value))
tags.save(filepath)
return True
def list_tracks(query=None):
"""Use beets (in-process, same container) to list tracks; paths are
already valid in this container's filesystem — no translation needed."""
args = ["beet", "ls", "-f", "$path"]
if query:
args.append(query)
r = subprocess.run(args, capture_output=True, text=True, timeout=120)
return [line for line in r.stdout.splitlines() if line.strip()]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="Actually write tags")
ap.add_argument("--force", action="store_true",
help="Overwrite existing GENRE tags (default: only fill empty)")
ap.add_argument("--query", help="Beets query to limit which tracks are processed")
ap.add_argument("--limit", type=int, help="Stop after N tracks (debug)")
args = ap.parse_args()
whitelist = load_whitelist()
print(f"[spotify-genre] whitelist has {len(whitelist)} entries")
paths = list_tracks(args.query)
if args.limit:
paths = paths[:args.limit]
print(f"[spotify-genre] {len(paths)} tracks to scan")
written = unchanged = no_match = no_meta = 0
for i, p in enumerate(paths, 1):
if i % 100 == 0:
print(f" ...{i}/{len(paths)}", flush=True)
if not os.path.exists(p):
continue
artist, title, current_genre = get_track_meta(p)
if not artist or not title:
no_meta += 1
continue
# Skip if manually genre-locked (set via fix-genre.sh or direct GENRE_LOCK tag)
if is_genre_locked(p):
unchanged += 1
continue
# Skip if already has whitelist-canonical genre and we're not forcing
if current_genre and not args.force:
cur_parts = [g.strip() for g in re.split(r"[;,]", current_genre)]
cur_normed = {re.sub(r"[\s\-_&/.]+", "", g).lower() for g in cur_parts}
if cur_normed & set(whitelist.keys()):
unchanged += 1
continue
# Multi-artist "X; Y" → use first as primary
primary_artist = re.split(r"[;,/&]", artist)[0].strip()
genres = spotify_artist_genres(primary_artist)
if genres is None or not genres:
no_match += 1
continue
matched = match_whitelist(genres, whitelist)
if not matched:
no_match += 1
continue
new_value = SEPARATOR.join(matched)
if current_genre == new_value:
unchanged += 1
continue
if args.apply:
ok = write_genre(p, new_value)
if ok:
written += 1
# Quiet — only print every 50th
if written % 50 == 0:
print(f" [{written}] {primary_artist} - {title}: {new_value}", flush=True)
else:
print(f" WOULD-SET {primary_artist} - {title}{new_value}")
written += 1 # count as 'planned'
print(f"\n[spotify-genre] {('written' if args.apply else 'plan')}: {written}, "
f"unchanged: {unchanged}, no-match: {no_match}, no-meta: {no_meta}")
if args.apply:
print("[spotify-genre] now run 'beet update' to sync the DB")
if __name__ == "__main__":
sys.exit(main())
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""
spotify-retag.py — rewrite tags on downloaded files using Spotify as source of truth.
Reads a Spotify playlist's tracks via Client Credentials, matches each audio file
in <scan_dir> to a Spotify track, and overwrites:
ARTIST — semicolon-joined list of all credited artists
ALBUMARTIST — primary (first) artist only — kills ghost combined artists
ALBUM
TITLE
TRACKNUMBER
DISCNUMBER
DATE — release year
Tags we leave alone:
GROUPING — set by run-playlist.sh for playlist-membership tracking
Anything else not in the list above
Usage:
spotify-retag.py <playlist_url> <scan_dir> # walk a directory tree
spotify-retag.py <playlist_url> - # read newline-separated paths from stdin
Credentials are read from env vars SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.
Matching strategy (per file):
1. Parse "Artist - Title.ext" from filename. If multiple " - " separators,
try every possible split position.
2. Normalize title (lowercase, strip parens/brackets/feat./punct).
3. Look up by normalized title in the playlist index. If exactly one hit,
use it. If multiple, score each by artist-string overlap and pick the
best. If nothing close, leave the file alone.
"""
import sys, os, re, json, base64, urllib.request, urllib.parse, subprocess
from pathlib import Path
from difflib import SequenceMatcher
API = "https://api.spotify.com/v1"
def get_token(cid: str, csec: str) -> str:
creds = base64.b64encode(f"{cid}:{csec}".encode()).decode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=urllib.parse.urlencode({"grant_type": "client_credentials"}).encode(),
headers={
"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())["access_token"]
def fetch_playlist(url: str, token: str) -> list[dict]:
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", url)
if not m:
sys.exit(f"Could not parse playlist ID from {url!r}")
pid = m.group(1)
tracks = []
next_url = f"{API}/playlists/{pid}/tracks?limit=100"
while next_url:
req = urllib.request.Request(
next_url, headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=15) as r:
data = json.loads(r.read())
for item in data["items"]:
t = item.get("track")
if not t or t.get("is_local"):
continue
tracks.append(
{
"id": t["id"],
"title": t["name"],
"artists": [a["name"] for a in t["artists"]],
"album": t["album"]["name"],
"track_number": t.get("track_number") or 0,
"disc_number": t.get("disc_number") or 1,
"year": (t["album"].get("release_date") or "").split("-")[0],
}
)
next_url = data.get("next")
return tracks
def normalize(s: str) -> str:
s = s.lower()
s = re.sub(r"\s*\([^)]*\)", "", s) # strip ( ... )
s = re.sub(r"\s*\[[^\]]*\]", "", s) # strip [ ... ]
s = re.sub(r"\s*-?\s*(extended|original|radio|club|vip|vocal)\s*(mix|edit|version)\s*$", "", s)
s = re.sub(r"\s*feat\.?\s.*", "", s) # strip "feat. X" trailing
s = re.sub(r"\s*ft\.?\s.*", "", s)
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def index_by_title(tracks: list[dict]) -> dict[str, list[dict]]:
idx: dict[str, list[dict]] = {}
for t in tracks:
key = normalize(t["title"])
# Fully non-Latin titles (Japanese/Korean/Cyrillic) normalize to "" —
# indexing them would dump every such track into one shared bucket and
# cross-match unrelated songs (their artists also normalize to "", so
# SequenceMatcher scores them 1.0). Leave them unmatched instead; the
# file keeps its sldl-supplied tags rather than gaining wrong ones.
if not key:
continue
idx.setdefault(key, []).append(t)
return idx
def candidate_splits(stem: str) -> list[tuple[str, str]]:
"""For 'A - B - C', return both forward and reversed splits, e.g.
[('A','B - C'), ('A - B','C'), # forward: artist - title
('B - C','A'), ('C','A - B')] # reversed: title - artist
Some Soulseek uploaders write 'Title - Artist' instead of 'Artist - Title'.
"""
parts = stem.split(" - ")
if len(parts) < 2:
return []
forward = [
(" - ".join(parts[:i]), " - ".join(parts[i:])) for i in range(1, len(parts))
]
reversed_ = [(b, a) for a, b in forward]
return forward + reversed_
def best_match(file_artist_raw: str, candidates: list[dict]) -> dict | None:
"""Pick the candidate whose artist-string overlaps best with the filename's."""
if len(candidates) == 1:
return candidates[0]
fnorm = normalize(file_artist_raw)
best, best_score = None, 0.0
for c in candidates:
# Compare against the joined artist string
joined = "; ".join(c["artists"])
score = SequenceMatcher(None, fnorm, normalize(joined)).ratio()
if score > best_score:
best, best_score = c, score
return best if best_score >= 0.4 else None
def match_file(path: Path, idx: dict[str, list[dict]]) -> dict | None:
stem = path.stem
for artist_guess, title_guess in candidate_splits(stem):
hits = idx.get(normalize(title_guess))
if hits:
picked = best_match(artist_guess, hits)
if picked:
return picked
# Last resort: try the whole stem as title (e.g. "Title.flac" with no " - ")
hits = idx.get(normalize(stem))
if hits and len(hits) == 1:
return hits[0]
return None
def write_flac_tags(path: str, t: dict, log) -> None:
artists_joined = "; ".join(t["artists"])
primary = t["artists"][0]
pairs = {
"ARTIST": artists_joined,
"ALBUMARTIST": primary,
"ALBUM": t["album"],
"TITLE": t["title"],
"TRACKNUMBER": str(t["track_number"]),
"DISCNUMBER": str(t["disc_number"]),
}
if t["year"]:
pairs["DATE"] = t["year"]
# Strip "release identification" tags from MB-tagged Soulseek uploads.
# Navidrome's BFR scanner uses these (alongside MB IDs) to differentiate
# releases of the same album, so leftover RELEASE*/BARCODE/CATALOGNUMBER/
# LABEL/MEDIA tags on one track will split it off into its own album card.
# Saw this with Danny Brown's Atrocity Exhibition where Pneumonia had MB
# IDs AND a CATALOGNUMBER and split off twice in succession before we
# broadened the strip list. Keep this list in sync with strip-mb-tags.sh.
stale_mb_tags = [
# MusicBrainz IDs
"MUSICBRAINZ_ALBUMID", "MUSICBRAINZ_RELEASEGROUPID",
"MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ_ALBUMARTISTID",
"MUSICBRAINZ_ARTISTID", "MUSICBRAINZ_ALBUMSTATUS",
"MUSICBRAINZ_ALBUMTYPE", "MUSICBRAINZ_TRACKID", "MUSICBRAINZ_WORKID",
# Release identification
"RELEASESTATUS", "RELEASETYPE", "RELEASECOUNTRY",
"BARCODE", "CATALOGNUMBER", "LABEL", "PUBLISHER",
"MEDIA", "ORIGINALDATE", "ASIN", "ISRC", "SCRIPT", "LANGUAGE",
# Duplicate / sort variants of artist fields (we use canonical ARTIST/ALBUMARTIST)
"ALBUM ARTIST", "ALBUM_ARTIST",
"ALBUMARTIST_CREDIT", "ALBUMARTISTSORT", "ALBUMARTISTS",
"ALBUM_ARTISTS", "ALBUMARTISTS_CREDIT", "ALBUMARTISTS_SORT",
"ARTIST_CREDIT", "ARTISTSORT", "ARTISTS",
"ARTISTS_CREDIT", "ARTISTS_SORT", "COMPOSERSORT",
# Misc cruft
"ACOUSTID_ID", "ACOUSTID_FINGERPRINT", "COMPILATION",
"YEAR", # duplicate of DATE
"DISCSUBTITLE", "DISCC", "TOTALDISCS", "TRACKC", "TOTALTRACKS",
# Kept in sync with mb-tags.sh (was missing these four)
"MUSICBRAINZ_ALBUMCOMMENT", "ALBUMCOMMENT",
"MUSICBRAINZ_DISCID", "MUSICBRAINZ_TRMID",
]
# One metaflac invocation: options are applied in order and the file is
# rewritten once, so a failure leaves the original tags intact — the old
# per-tag loop (~65 subprocesses) could die between remove and set, leaving
# a file stripped of ARTIST/TITLE.
args = ["metaflac"]
args += [f"--remove-tag={tag}" for tag in list(pairs) + stale_mb_tags]
args += [f"--set-tag={tag}={val}" for tag, val in pairs.items()]
args.append(path)
subprocess.run(args, check=True, stderr=log)
def write_mp3_tags(path: str, t: dict, log) -> None:
artists_joined = "; ".join(t["artists"])
args = [
"id3v2",
"--TPE1", artists_joined,
"--TPE2", t["artists"][0],
"--TALB", t["album"],
"--TIT2", t["title"],
"--TRCK", str(t["track_number"]),
"--TPOS", str(t["disc_number"]),
]
if t["year"]:
args += ["--TYER", t["year"]]
args.append(path)
subprocess.run(args, check=True, stderr=log)
def main() -> int:
if len(sys.argv) != 3:
sys.exit(f"Usage: {sys.argv[0]} <playlist_url> <scan_dir|->")
url, target = sys.argv[1], sys.argv[2]
cid = os.environ.get("SPOTIFY_CLIENT_ID")
csec = os.environ.get("SPOTIFY_CLIENT_SECRET")
if not cid or not csec:
sys.exit("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set")
if target == "-":
files_iter = (Path(line.strip()) for line in sys.stdin if line.strip())
else:
files_iter = Path(target).rglob("*")
print(f"[spotify-retag] Fetching playlist {url}")
token = get_token(cid, csec)
tracks = fetch_playlist(url, token)
print(f"[spotify-retag] Got {len(tracks)} tracks from Spotify")
idx = index_by_title(tracks)
matched = unmatched = failed = 0
unmatched_names: list[str] = []
for f in sorted(files_iter):
if not f.is_file() or f.suffix.lower() not in (".flac", ".mp3"):
continue
match = match_file(f, idx)
if not match:
unmatched += 1
unmatched_names.append(f.name)
continue
try:
if f.suffix.lower() == ".flac":
write_flac_tags(str(f), match, sys.stderr)
else:
write_mp3_tags(str(f), match, sys.stderr)
matched += 1
print(f" OK {match['artists'][0]}{match['title']} ({f.name})")
except subprocess.CalledProcessError as e:
failed += 1
print(f" FAIL {f.name}: {e}", file=sys.stderr)
print(f"\n[spotify-retag] {matched} matched, {unmatched} unmatched, {failed} failed")
for u in unmatched_names[:25]:
print(f" unmatched: {u}")
if len(unmatched_names) > 25:
print(f" ...and {len(unmatched_names) - 25} more")
return 0
if __name__ == "__main__":
sys.exit(main())
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Bulk-strip "release identification" tags from every FLAC in the library.
# These come from MB-tagged Soulseek uploads; Navidrome's BFR scanner uses them
# to differentiate releases of the same album, which causes one MB-tagged track
# to split off into its own album card alongside the non-MB-tagged siblings.
#
# Run after a bulk import of MB-tagged files, or whenever you see album cards
# split in Navidrome with one track in one card and the rest in another.
#
# The tag list and per-file strip logic live in lib/mb-tags.sh, which is also
# sourced by import-track.sh to strip MB tags on every manual import.
set -u
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/mb-strip-$(date +%Y%m%d-%H%M%S).log
mkdir -p "$(dirname "$LOG")"
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/mb-tags.sh
source ${PIPELINE_DIR:-/app/pipeline}/lib/mb-tags.sh
echo "[$(date -Iseconds)] Stripping release-identification tags from FLAC files in ${MUSIC_DATA_DIR:-/data/music}/Library" | tee -a "$LOG"
COUNT=$(strip_mb_tags ${MUSIC_DATA_DIR:-/data/music}/Library "$LOG")
echo "[$(date -Iseconds)] Stripped tags from $COUNT FLAC files" | tee -a "$LOG"
echo "[$(date -Iseconds)] Syncing beets DB..." | tee -a "$LOG"
beet update 2>&1 | tail -5 >> "$LOG"
echo "[$(date -Iseconds)] Done. Log: $LOG" | tee -a "$LOG"
echo "[$(date -Iseconds)] Trigger a Navidrome scan now to re-evaluate album grouping" | tee -a "$LOG"
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
strip-watermark-art.py — find embedded cover art that appears across multiple
unrelated albums (common Soulseek-uploader watermarks like djsoundtop.com,
iptorrents.com, torrentday.com, electronicfresh.com, etc.). Strip those
images from the offending files; beets' fetchart can re-fetch real cover
art on the next pass.
Heuristic: a real cover.jpg appears in tracks of one (albumartist, album)
pair. A watermark image appears across many. Anything where the same image
hash appears in >= --threshold distinct albums is flagged.
Handles both FLAC (via metaflac) and MP3 (via mutagen, apt-installed
python3-mutagen).
Usage:
strip-watermark-art.py # dry run
strip-watermark-art.py --apply # strip suspicious art
strip-watermark-art.py --threshold 5 # tune (default 3)
"""
import sys, os, hashlib, subprocess, tempfile, argparse
from pathlib import Path
from collections import defaultdict
from mutagen.id3 import ID3, ID3NoHeaderError, APIC
from mutagen.mp3 import MP3
LIBRARY = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library"
def build_album_index():
"""Single beets call → {path: (albumartist, album)} dict. beets runs
in-process in this same container, so paths need no translation."""
print("[strip-art] loading beets album index...")
r = subprocess.run(
["beet", "ls", "-f", "$path‖$albumartist‖$album"],
capture_output=True, text=True, timeout=120
)
idx = {}
for line in r.stdout.splitlines():
parts = line.split("", 2)
if len(parts) != 3:
continue
path, aa, al = parts
idx[path] = (aa, al)
print(f"[strip-art] indexed {len(idx)} library tracks")
return idx
def extract_flac_picture(flac_path):
"""Returns (sha256_hash, size) of the first embedded picture, or None."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".pic") as tf:
tmp = tf.name
try:
r = subprocess.run(
["metaflac", f"--export-picture-to={tmp}", flac_path],
capture_output=True, timeout=15
)
if r.returncode != 0 or not os.path.exists(tmp):
return None
sz = os.path.getsize(tmp)
if sz == 0:
return None
with open(tmp, "rb") as f:
return hashlib.sha256(f.read()).hexdigest(), sz
finally:
try: os.unlink(tmp)
except FileNotFoundError: pass
def extract_mp3_picture(mp3_path):
"""Returns (sha256_hash, size) of the first APIC payload, or None."""
try:
tags = ID3(mp3_path)
except (ID3NoHeaderError, Exception):
return None
for k in tags.keys():
if not k.startswith("APIC"):
continue
frame = tags[k]
data = frame.data
if data and len(data) > 100:
return hashlib.sha256(data).hexdigest(), len(data)
return None
def strip_mp3_pictures(mp3_path):
"""Remove all APIC frames from an MP3. Returns True on success."""
try:
tags = ID3(mp3_path)
keys = [k for k in tags.keys() if k.startswith("APIC")]
for k in keys:
del tags[k]
tags.save(mp3_path)
return True
except Exception:
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Actually strip suspicious art (default: dry run)")
ap.add_argument("--threshold", type=int, default=3,
help="Min distinct albums sharing an image to flag it (default 3)")
args = ap.parse_args()
album_idx = build_album_index()
# hash -> {albums: set of (aa, al), files: [paths], size: int}
images = defaultdict(lambda: {"albums": set(), "files": [], "size": 0})
audio_files = (
list(Path(LIBRARY).rglob("*.flac")) +
list(Path(LIBRARY).rglob("*.mp3"))
)
print(f"[strip-art] scanning {len(audio_files)} audio files for embedded art...")
for i, p in enumerate(audio_files, 1):
if i % 200 == 0:
print(f" ...{i}/{len(audio_files)}", flush=True)
sfx = p.suffix.lower()
if sfx == ".flac":
res = extract_flac_picture(str(p))
elif sfx == ".mp3":
res = extract_mp3_picture(str(p))
else:
continue
if not res:
continue
h, sz = res
aa, al = album_idx.get(str(p), ("", ""))
images[h]["albums"].add((aa, al))
images[h]["files"].append(str(p))
images[h]["size"] = sz
suspicious = {h: info for h, info in images.items()
if len(info["albums"]) >= args.threshold}
if not suspicious:
print(f"\n[strip-art] no images shared across >= {args.threshold} distinct albums.")
print("[strip-art] If watermarks remain, lower --threshold or check MP3s manually.")
return 0
print(f"\n[strip-art] {len(suspicious)} suspicious image(s):\n")
for h, info in sorted(suspicious.items(), key=lambda kv: -len(kv[1]["files"])):
print(f" {h[:16]}{info['size']} bytes "
f"in {len(info['files'])} files across {len(info['albums'])} albums:")
for aa, al in sorted(info["albums"])[:6]:
print(f" {aa or '?'} / {al or '?'}")
if len(info["albums"]) > 6:
print(f" ...and {len(info['albums']) - 6} more")
print()
if not args.apply:
n = sum(len(s["files"]) for s in suspicious.values())
print(f"[strip-art] DRY RUN — re-run with --apply to strip from {n} FLAC files")
return 0
stripped = 0
for info in suspicious.values():
for f in info["files"]:
if f.lower().endswith(".flac"):
r = subprocess.run(
["metaflac", "--remove", "--block-type=PICTURE", f],
capture_output=True
)
if r.returncode == 0:
stripped += 1
elif f.lower().endswith(".mp3"):
if strip_mp3_pictures(f):
stripped += 1
print(f"\n[strip-art] stripped picture blocks from {stripped} files")
print("[strip-art] next: re-fetch real cover art with:")
print(" beet fetchart")
if __name__ == "__main__":
sys.exit(main())
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env python3
"""
sync-bandcamp.py — download new items from a Bandcamp purchase history.
Reads a Netscape-format cookies file, queries Bandcamp's collection API to
find every item the authenticated user has purchased, then downloads each
new item (one not in the state file yet) as a ZIP, extracts the audio files,
and records the item in the state file.
Stdlib only — no third-party deps.
Usage:
sync-bandcamp.py <username> <cookies_file> <state_file> <output_dir> [<format_pref>]
format_pref is a space-separated preference list (default: "flac mp3-320").
First match wins.
Exit code 0 even if some items failed (we want cron to keep trying); details
are logged. Exit code !=0 only on auth/setup problems where retrying without
human intervention is pointless.
"""
import sys, os, re, json, time, html, subprocess
import urllib.request, urllib.parse
from http.cookiejar import MozillaCookieJar
from pathlib import Path
import zipfile, io
import traceback
COLLECTION_ITEMS_URL = "https://bandcamp.com/api/fancollection/1/collection_items"
UA = "Mozilla/5.0 (X11; Linux x86_64) sync-bandcamp/1.0"
REQUEST_TIMEOUT = 60
DOWNLOAD_TIMEOUT = 600 # ZIPs can be large
SLEEP_BETWEEN = 2.0 # be polite
def opener_for(jar):
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar)
)
def http_get(url, jar, timeout=REQUEST_TIMEOUT):
req = urllib.request.Request(url, headers={"User-Agent": UA})
return opener_for(jar).open(req, timeout=timeout)
def http_post_json(url, body, jar, timeout=REQUEST_TIMEOUT):
req = urllib.request.Request(
url,
data=json.dumps(body).encode(),
headers={
"User-Agent": UA,
"Content-Type": "application/json",
"Accept": "application/json",
"Referer": "https://bandcamp.com/",
},
)
return opener_for(jar).open(req, timeout=timeout)
def get_fan_id(username, jar):
"""The fan_id is inside the data-blob JSON on the user's collection page.
Bandcamp HTML-escapes the JSON inside the attribute, so we have to
unescape before matching."""
resp = http_get(f"https://bandcamp.com/{username}", jar)
page = resp.read().decode("utf-8", errors="replace")
page_unescaped = html.unescape(page)
m = re.search(r'"fan_id"\s*:\s*"?(\d+)', page_unescaped)
if not m:
sys.stderr.write("[bandcamp] Could not find fan_id on profile page.\n")
sys.stderr.write("[bandcamp] Likely your cookies file is missing or expired,\n")
sys.stderr.write("[bandcamp] or the username in config.env is wrong.\n")
sys.exit(2)
return int(m.group(1))
def fetch_collection(fan_id, jar):
"""Page through the collection. Returns (items, redownload_urls).
items: list of dicts (Bandcamp's per-item JSON)
redownload_urls: dict like {"p12345": "https://bandcamp.com/download?...", ...}
keyed by <type-letter><sale_item_id>.
"""
all_items = []
all_redownload = {}
older_than_token = "9999999999::a::" # paginate from newest
while True:
body = {
"fan_id": fan_id,
"older_than_token": older_than_token,
"count": 100,
}
resp = http_post_json(COLLECTION_ITEMS_URL, body, jar)
data = json.loads(resp.read())
page_items = data.get("items", [])
page_redownload = data.get("redownload_urls", {}) or {}
all_items.extend(page_items)
all_redownload.update(page_redownload)
if not data.get("more_available") or not page_items:
break
last_token = data.get("last_token")
if not last_token or last_token == older_than_token:
break
older_than_token = last_token
return all_items, all_redownload
def parse_data_blob(html_text):
"""Bandcamp embeds a JSON 'data-blob' attribute on <div id="pagedata">.
Extract and decode it."""
m = re.search(r'id="pagedata"[^>]*data-blob="([^"]+)"', html_text)
if not m:
return None
raw = m.group(1)
raw = html.unescape(raw)
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
def get_format_url(redownload_url, format_pref, jar):
"""Load the download landing page and return the signed URL for the
preferred format. Returns (format_chosen, url) or (None, None)."""
resp = http_get(redownload_url, jar, timeout=REQUEST_TIMEOUT)
page = resp.read().decode("utf-8", errors="replace")
blob = parse_data_blob(page)
if not blob:
return None, None
download_items = blob.get("download_items") or []
if not download_items:
return None, None
item = download_items[0]
downloads = item.get("downloads") or {}
for fmt in format_pref:
if fmt in downloads and downloads[fmt].get("url"):
return fmt, downloads[fmt]["url"]
return None, None
def stat_url_until_ready(stat_url, jar, max_wait_sec=120):
"""Bandcamp sometimes returns a 'stat' URL that has to be polled until
download is signed. Returns the final download_url, or None if it
never becomes ready."""
deadline = time.time() + max_wait_sec
last = None
while time.time() < deadline:
# The stat endpoint expects ?.rand=<num>
url = stat_url + ("&" if "?" in stat_url else "?") + ".rand=" + str(int(time.time() * 1000))
try:
resp = http_get(url, jar, timeout=30)
text = resp.read().decode("utf-8", errors="replace")
# Strip JSONP wrapper if present
text = re.sub(r"^\s*[A-Za-z_]\w*\s*\(", "", text)
text = re.sub(r"\)\s*;?\s*$", "", text)
data = json.loads(text)
for k, v in (data.get("download_url_stat") or {}).items():
last = v
if last and last.get("download_ready"):
return last.get("download_url")
except Exception:
pass
time.sleep(3)
return None
def download_payload(url, jar):
"""Returns (bytes, suggested_filename) — Bandcamp returns either a ZIP
(for albums / multi-track purchases) or a single audio file (for single-
track purchases). The Content-Disposition header tells us which."""
resp = http_get(url, jar, timeout=DOWNLOAD_TIMEOUT)
cd = resp.headers.get("Content-Disposition", "")
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
suggested = m.group(1) if m else ""
suggested = urllib.parse.unquote(suggested).strip()
return resp.read(), suggested
def safe_name(s):
"""Make a string safe for filesystem use (and not too long)."""
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", s)
s = s.strip().rstrip(".")
return s[:80] if len(s) > 80 else s
_AUDIO_EXTS = (".flac", ".mp3", ".m4a", ".aac", ".ogg", ".oga", ".alac",
".wav", ".aiff", ".aif")
_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
# --- Buy-link tagging -------------------------------------------------------
# We stamp the release's Bandcamp page into the file so AzuraCast can show a
# "Buy" button on ephemeral.club. AzuraCast only auto-assigns custom fields
# from its *known* tag enum, so a bespoke "BUY_URL" tag would be ignored
# (it lands in extraTags). Instead we use the "Commercial Information" tag —
# ID3 frame WCOM, getID3 key `commercial_information`, semantically "a webpage
# with information such as where the album can be bought". AzuraCast's custom
# field `buy_url` has auto_assign=commercial_information, so this surfaces in
# now_playing.song.custom_fields.buy_url. See README "Buy links" section.
BUY_URL_TAG = "COMMERCIAL_INFORMATION" # FLAC Vorbis comment name
def tag_buy_url(filepath, url):
"""Best-effort: stamp the purchasable URL into the file's commercial-
information tag. Never fails the download over a tagging error."""
if not url:
return
low = filepath.lower()
try:
if low.endswith(".flac"):
subprocess.run(["metaflac", f"--remove-tag={BUY_URL_TAG}", filepath],
capture_output=True, check=False)
subprocess.run(["metaflac", f"--set-tag={BUY_URL_TAG}={url}", filepath],
capture_output=True, check=False)
elif low.endswith(".mp3"):
# WCOM = "Commercial information" URL link frame.
from mutagen.id3 import ID3, WCOM, ID3NoHeaderError
try:
tags = ID3(filepath)
except ID3NoHeaderError:
tags = ID3()
tags.delall("WCOM")
tags.add(WCOM(url=url))
tags.save(filepath)
except Exception as e:
sys.stderr.write(f"[bandcamp] WARN: buy_url tag failed on {filepath}: {e}\n")
def save_payload(payload, suggested_name, dest_dir, item_title=None, buy_url=None):
"""Save a Bandcamp download — either ZIP (extract audio) or raw audio
(write directly). For raw single-track audio, ensure an ALBUM tag is set
so the file doesn't land as [Unknown Album] in Navidrome. Each audio file
is stamped with the release's buy_url (see tag_buy_url).
Returns count of audio files written."""
# Detect ZIP by magic bytes
is_zip = payload[:4] == b"PK\x03\x04" or payload[:4] == b"PK\x05\x06"
if is_zip:
count = 0
with zipfile.ZipFile(io.BytesIO(payload)) as z:
for n in z.namelist():
low = n.lower()
if low.endswith(_AUDIO_EXTS):
outp = z.extract(n, dest_dir)
tag_buy_url(outp, buy_url)
count += 1
elif low.endswith(_IMAGE_EXTS):
z.extract(n, dest_dir) # cover art for beets fetchart
return count
# Raw audio file (single-track purchase) — Bandcamp serves these without
# any ALBUM tag, so we tag them ourselves before they hit the pipeline.
name = suggested_name or "track"
low = name.lower()
if not low.endswith(_AUDIO_EXTS):
if payload[:4] == b"fLaC":
name = (name or "track") + ".flac"
elif payload[:3] == b"ID3" or payload[:2] == b"\xff\xfb":
name = (name or "track") + ".mp3"
else:
return 0 # unknown format
name = safe_name(name)
out = os.path.join(dest_dir, name)
with open(out, "wb") as f:
f.write(payload)
# Set ALBUM = "{item_title} - Single" if the file has no ALBUM tag.
# Matches Spotify/Apple Music's convention for single-track releases.
if item_title:
_ensure_album_tag(out, f"{item_title} - Single")
tag_buy_url(out, buy_url)
return 1
def _ensure_album_tag(filepath, fallback_album):
"""If the file has no ALBUM tag, set it. No-op if one already exists."""
low = filepath.lower()
try:
if low.endswith(".flac"):
r = subprocess.run(
["metaflac", "--show-tag=ALBUM", filepath],
capture_output=True, text=True
)
if "ALBUM=" in r.stdout:
return
subprocess.run(
["metaflac", f"--set-tag=ALBUM={fallback_album}", filepath],
check=True, stderr=subprocess.DEVNULL,
)
elif low.endswith(".mp3"):
r = subprocess.run(
["id3v2", "-l", filepath],
capture_output=True, text=True
)
if re.search(r"^TALB\b", r.stdout, re.MULTILINE):
return
subprocess.run(
["id3v2", "--TALB", fallback_album, filepath],
check=True, stderr=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, FileNotFoundError):
pass # best-effort; don't fail the whole download over this
def load_state(path):
if not os.path.exists(path):
return {}
try:
with open(path) as f:
return json.load(f)
except json.JSONDecodeError:
sys.stderr.write(f"[bandcamp] WARN: {path} is corrupt; starting fresh\n")
return {}
def save_state(path, state):
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f, indent=2, sort_keys=True)
os.replace(tmp, path)
def main():
if len(sys.argv) < 5:
sys.exit(f"Usage: {sys.argv[0]} <username> <cookies_file> <state_file> <output_dir> [<format_pref>]")
username = sys.argv[1]
cookies_file = sys.argv[2]
state_file = sys.argv[3]
output_dir = sys.argv[4]
format_pref = sys.argv[5].split() if len(sys.argv) > 5 else ["flac", "mp3-320"]
if not os.path.exists(cookies_file):
sys.stderr.write(f"[bandcamp] ERROR: cookies file not found: {cookies_file}\n")
sys.stderr.write("[bandcamp] Export cookies for bandcamp.com from a logged-in browser tab\n")
sys.stderr.write("[bandcamp] (e.g. via the 'Get cookies.txt LOCALLY' extension) and save here.\n")
sys.exit(2)
jar = MozillaCookieJar(cookies_file)
try:
jar.load(ignore_discard=True, ignore_expires=True)
except Exception as e:
sys.stderr.write(f"[bandcamp] ERROR: cookies file unreadable: {e}\n")
sys.exit(2)
Path(output_dir).mkdir(parents=True, exist_ok=True)
state = load_state(state_file)
print(f"[bandcamp] auth as {username!r}, format pref={format_pref}")
fan_id = get_fan_id(username, jar)
print(f"[bandcamp] fan_id={fan_id}")
items, redownload = fetch_collection(fan_id, jar)
print(f"[bandcamp] collection has {len(items)} items, {len(redownload)} download URLs")
new_count = skipped = failed = 0
for item in items:
sale_id = item.get("sale_item_id")
if not sale_id:
continue
sale_id_str = str(sale_id)
if sale_id_str in state:
skipped += 1
continue
band = item.get("band_name") or "Unknown Artist"
title = item.get("item_title") or f"item-{item.get('item_id')}"
buy_url = item.get("item_url") # the release's Bandcamp page (purchasable)
item_type = item.get("item_type", "package") # "track" or "package" (album)
type_prefix = "p" if item_type in ("package", "album") else "t"
url_key = f"{type_prefix}{sale_id}"
download_landing = redownload.get(url_key)
if not download_landing:
# Sometimes the key uses the OTHER prefix
alt_prefix = "t" if type_prefix == "p" else "p"
download_landing = redownload.get(f"{alt_prefix}{sale_id}")
if not download_landing:
print(f"[bandcamp] NO_URL {band}{title}")
failed += 1
continue
try:
fmt, signed_url = get_format_url(download_landing, format_pref, jar)
if not signed_url:
print(f"[bandcamp] NO_FORMAT {band}{title} (preferred: {format_pref})")
failed += 1
continue
# Some signed_urls are direct, some are "stat" URLs that need polling
actual_url = signed_url
if "/statdownload/" in signed_url:
polled = stat_url_until_ready(signed_url, jar)
if polled:
actual_url = polled
else:
print(f"[bandcamp] STAT_TIMEOUT {band}{title}")
failed += 1
continue
payload, suggested = download_payload(actual_url, jar)
dest = Path(output_dir) / safe_name(band) / safe_name(title)
dest.mkdir(parents=True, exist_ok=True)
n = save_payload(payload, suggested, str(dest), item_title=title,
buy_url=buy_url)
if n == 0:
print(f"[bandcamp] EMPTY_PAYLOAD {band}{title} (got {len(payload)} bytes, type unknown)")
failed += 1
continue
state[sale_id_str] = {
"downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"band": band,
"title": title,
"format": fmt,
"files": n,
"path": str(dest),
"buy_url": buy_url,
}
save_state(state_file, state) # save after each — survives interruption
print(f"[bandcamp] OK ({fmt}, {n} files) {band}{title}")
new_count += 1
time.sleep(SLEEP_BETWEEN)
except Exception as e:
sys.stderr.write(f"[bandcamp] FAIL {band}{title}: {e}\n")
traceback.print_exc(file=sys.stderr)
failed += 1
print(f"\n[bandcamp] === {new_count} new, {skipped} already had, {failed} failed ===")
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# Wrapper: sync new Bandcamp purchases, then push them through the standard
# import-track.sh pipeline so they end up in the Navidrome library tagged
# the same way as everything else.
#
# Reads $ALEMBIC_CONFIG_DIR/pipeline/bandcamp/config.env for username, format pref, etc.
# Run as root (cron). Logs to ${ALEMBIC_CONFIG_DIR:-/config}/logs/bandcamp-YYYYMMDD.log.
set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
CONFIG="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/config.env"
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/bandcamp-$(date +%Y%m%d).log
SYNC_PY=${PIPELINE_DIR:-/app/pipeline}/lib/sync-bandcamp.py
IMPORT_ME=${MUSIC_DATA_DIR:-/data/music}/import-me
mkdir -p "$(dirname "$LOG")"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
if [[ ! -f "$CONFIG" ]]; then
log "ERROR: $CONFIG not found"
exit 1
fi
# shellcheck disable=SC1090
source "$CONFIG"
if [[ ! -f "$BANDCAMP_COOKIES" ]]; then
log "ERROR: cookies file missing at $BANDCAMP_COOKIES"
log " Export Bandcamp cookies in Netscape format (Get cookies.txt LOCALLY"
log " extension, while logged into bandcamp.com), save to that path."
exit 1
fi
# Refuse to run against a dead/empty share: mkdir -p below would otherwise
# silently create shadow directories inside the container, downloads would
# land there invisibly, and state.json would mark those purchases as done —
# permanently skipping them after the mount comes back. `mountpoint -q` (the
# original host-side check) doesn't apply to a bind-mounted container path;
# a canary file + minimum artist-dir count is the container-compatible
# equivalent (same pattern as navidrome-scan.sh's share-health gate).
LIB="${MUSIC_DATA_DIR:-/data/music}/Library"
CANARY="$LIB/.navidrome-canary"
MIN_ARTIST_DIRS=500
if [[ ! -r "$CANARY" ]]; then
log "ERROR: canary $CANARY missing/unreadable — share looks unhealthy — aborting sync"
exit 1
fi
n=$(find "$LIB" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
if [[ "$n" -lt "$MIN_ARTIST_DIRS" ]]; then
log "ERROR: only $n artist dirs under $LIB (< $MIN_ARTIST_DIRS) — library looks truncated — aborting sync"
exit 1
fi
mkdir -p "$BANDCAMP_STAGING" "$IMPORT_ME"
log "=== Bandcamp sync starting ==="
log "user=$BANDCAMP_USERNAME staging=$BANDCAMP_STAGING format=$BANDCAMP_FORMAT_PREF"
if ! python3 "$SYNC_PY" \
"$BANDCAMP_USERNAME" \
"$BANDCAMP_COOKIES" \
"$BANDCAMP_STATE" \
"$BANDCAMP_STAGING" \
"$BANDCAMP_FORMAT_PREF" >> "$LOG" 2>&1; then
log "WARN: sync-bandcamp.py exited non-zero (likely auth issue or network)"
fi
# Anything new? If staging dir has audio, hand it off to import-track.sh.
NEW_COUNT=$(find "$BANDCAMP_STAGING" -type f \( -iname "*.flac" -o -iname "*.mp3" -o -iname "*.wav" -o -iname "*.m4a" \) 2>/dev/null | wc -l)
if [[ "$NEW_COUNT" -eq 0 ]]; then
log "No new files to import"
log "=== Bandcamp sync done (no-op) ==="
exit 0
fi
log "$NEW_COUNT new audio file(s) staged; moving to $IMPORT_ME for import"
# Move everything from staging into import-me. Preserve the band/album folder
# structure since import-track.sh handles directories fine.
shopt -s dotglob nullglob
for entry in "$BANDCAMP_STAGING"/*; do
[[ -e "$entry" ]] || continue
mv "$entry" "$IMPORT_ME/" 2>>"$LOG"
done
shopt -u dotglob nullglob
# Hand off to the standard manual-import pipeline. No playlist tag — Bandcamp
# purchases aren't part of any Spotify playlist. The import-track.sh guard,
# albumartist fallback, beets import, and Navidrome scan all kick in normally.
log "Calling import-track.sh"
if ${PIPELINE_DIR:-/app/pipeline}/bin/import-track.sh >> "$LOG" 2>&1; then
log "import-track.sh OK"
else
log "WARN: import-track.sh exited non-zero (exit $?)"
fi
# Safety net: if anything is stranded in ${MUSIC_DATA_DIR:-/data/music}/downloads (rare with
# duplicate_action=keep set in beets config, but possible for files beets
# couldn't process), find each one's library counterpart and replace if the
# new file is higher quality, OR import as new if there's no counterpart.
log "Running replace-with-better safety pass on /downloads stragglers"
if ${PIPELINE_DIR:-/app/pipeline}/lib/replace-with-better.sh --apply >> "$LOG" 2>&1; then
log "replace-with-better OK"
else
log "WARN: replace-with-better.sh exited non-zero (exit $?)"
fi
# Now dedupe across the library. With duplicate_action=keep beets imported
# every Bandcamp file even when it conflicts with an existing track at the
# same path (it creates `.1.ext` siblings). dedup-library.sh's policy is
# "FLAC > MP3, then largest file" — Bandcamp version wins, soulseek version
# is removed from both the beets DB and disk.
log "Running dedup pass (Bandcamp FLAC wins over older Soulseek copies)"
if ${PIPELINE_DIR:-/app/pipeline}/lib/dedup-library.sh --apply >> "$LOG" 2>&1; then
log "dedup OK"
else
log "WARN: dedup-library.sh exited non-zero (exit $?)"
fi
log "=== Bandcamp sync done ==="
exit 0
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# ${PIPELINE_DIR:-/app/pipeline}/lib/tag-guard.sh
#
# Shared helper sourced by run-playlist.sh and import-track.sh.
# Moves audio files with missing ARTIST or TITLE tags into a quarantine dir,
# so they never reach beets and rot the library as "[Unknown Album]".
#
# Usage: quarantine_untagged <scan_dir> <quarantine_dir> <log_file>
# Returns: number of files moved (via stdout)
# Separator pattern matching Navidrome's artist split list in navidrome.toml.
# Used by set_albumartist_fallback to extract the primary artist from a multi-
# artist string. Keep these two lists in sync.
#
# Only unambiguous separators here. ", " and " & " are NOT included because they
# appear inside real band names ("Tyler, The Creator", "Simon & Garfunkel",
# "piri & tommy"). For collab strings using those separators, ALBUMARTIST falls
# back to the full string — Navidrome will show one combined artist for the album,
# which is the lesser of two evils vs. corrupting real band names.
_PRIMARY_ARTIST_SEP_PATTERN=' / | feat\. | feat | ft\. | ft |; '
# For each audio file under $scan_dir that has no ALBUMARTIST tag, set
# ALBUMARTIST to the primary artist (everything before the first separator
# in the ARTIST tag). Prevents Navidrome from coining ghost album-artists
# like "Fred Again, The Blessed Madonna" when a track is a collab.
#
# Usage: set_albumartist_fallback <scan_dir> <log_file>
# Returns: number of files updated (via stdout)
set_albumartist_fallback() {
local scan_dir="$1"
local logfile="$2"
local count=0
while IFS= read -r -d '' file; do
case "${file##*.}" in
flac|FLAC)
local cur_aa artist primary
cur_aa=$(metaflac --show-tag=ALBUMARTIST "$file" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
[[ -n "$cur_aa" ]] && continue
artist=$(metaflac --show-tag=ARTIST "$file" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
[[ -z "$artist" ]] && continue
primary=$(echo "$artist" | sed -E "s#(${_PRIMARY_ARTIST_SEP_PATTERN}).*##")
metaflac --set-tag="ALBUMARTIST=${primary}" "$file" 2>>"$logfile"
count=$((count + 1))
;;
mp3|MP3)
local cur_aa artist primary
cur_aa=$(id3v2 -l "$file" 2>/dev/null | sed -n 's/^TPE2[^:]*: //p' | head -1)
[[ -n "$cur_aa" ]] && continue
artist=$(id3v2 -l "$file" 2>/dev/null | sed -n 's/^TPE1[^:]*: //p' | head -1)
[[ -z "$artist" ]] && continue
primary=$(echo "$artist" | sed -E "s#(${_PRIMARY_ARTIST_SEP_PATTERN}).*##")
id3v2 --TPE2 "$primary" "$file" 2>>"$logfile"
count=$((count + 1))
;;
esac
done < <(find "$scan_dir" -type f \( -iname "*.flac" -o -iname "*.mp3" \) -print0 2>/dev/null)
echo "$count"
}
quarantine_untagged() {
local scan_dir="$1"
local quarantine_dir="$2"
local logfile="$3"
local moved=0
mkdir -p "$quarantine_dir"
_has_flac_tags() {
local artist title
artist=$(metaflac --show-tag=ARTIST "$1" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
title=$(metaflac --show-tag=TITLE "$1" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
[[ -n "$artist" && -n "$title" ]]
}
_has_mp3_tags() {
local artist title
artist=$(id3v2 -l "$1" 2>/dev/null | sed -n 's/^TPE1[^:]*: //p' | head -1)
title=$(id3v2 -l "$1" 2>/dev/null | sed -n 's/^TIT2[^:]*: //p' | head -1)
[[ -n "$artist" && -n "$title" ]]
}
while IFS= read -r -d '' file; do
local ok=1
case "${file##*.}" in
flac|FLAC) _has_flac_tags "$file" || ok=0 ;;
mp3|MP3) _has_mp3_tags "$file" || ok=0 ;;
*) ok=0 ;; # unknown format — beets can't handle it anyway
esac
if [[ $ok -eq 0 ]]; then
echo "[$(date -Iseconds)] QUARANTINE: missing artist/title — $(basename "$file")" >> "$logfile"
mv -n "$file" "$quarantine_dir/" 2>>"$logfile"
moved=$((moved + 1))
fi
done < <(find "$scan_dir" -type f \( -iname "*.flac" -o -iname "*.mp3" -o -iname "*.m4a" \) -print0 2>/dev/null)
echo "$moved"
}
+180
View File
@@ -0,0 +1,180 @@
#!/bin/bash
# upgrade-mp3-to-flac.sh — for every MP3 currently in the library, ask sldl
# to find a FLAC version of the same track. If found, replace the MP3.
# If not found, the MP3 stays (we don't lose anything).
#
# Strategy:
# 1. Build a CSV from beets with one row per MP3 track.
# 2. Run sldl with --format flac (HARD format requirement, not preferred —
# sldl will skip any track where no FLAC is available rather than
# falling back to MP3).
# 3. For each FLAC that arrives in the staging dropbox, run
# replace-with-better.sh which already knows the FLAC > MP3 rule.
#
# Conservative — if Soulseek peers are slow that day or the FLAC isn't
# available, we leave the MP3 alone. Re-runs are cheap; sldl's m3u skip
# logic means it won't re-search tracks it has already FLAC-found.
#
# Usage:
# upgrade-mp3-to-flac.sh # run the scanner end-to-end
# upgrade-mp3-to-flac.sh --csv-only # just write the CSV; don't run sldl
set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
# shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/tag-guard.sh
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/mb-tags.sh
source ${PIPELINE_DIR:-/app/pipeline}/lib/mb-tags.sh
CSV_ONLY=0
[[ "${1:-}" == "--csv-only" ]] && CSV_ONLY=1
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/upgrade-mp3-$(date +%Y%m%d-%H%M%S).log
STAGING_HOST=${MUSIC_DATA_DIR:-/data/music}/sldl-dropbox/_upgrade
QUARANTINE_DIR=${MUSIC_DATA_DIR:-/data/music}/Songs/untagged
CSV_HOST=${ALEMBIC_CONFIG_DIR:-/config}/pipeline/_upgrade.csv
SLDL_BIN=${PIPELINE_DIR:-/app/pipeline}/sldl
mkdir -p "$(dirname "$LOG")" "$STAGING_HOST"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
log "=== MP3 upgrade scan starting ==="
# Build CSV from beets. sldl's CSV format: comma-separated with header
# Artist, Title, Album, Length (sec), URI (optional). We omit URI; sldl
# falls back to a Soulseek text search when no URI is given.
log "Querying beets for MP3 tracks..."
beet ls -f '$artist|$title|$album|$length' 'format:mp3' 2>/dev/null > /tmp/mp3-rows.txt
COUNT=$(wc -l < /tmp/mp3-rows.txt)
log " $COUNT MP3 tracks to attempt"
# Convert | to , and quote fields that contain commas
{
echo "Artist,Title,Album,Length"
awk -F'|' 'BEGIN{OFS=""} {
for (i=1; i<=NF; i++) {
g = $i; gsub(/"/, "\"\"", g)
if (g ~ /[,"]/) g = "\"" g "\""
$i = g
}
print $1 "," $2 "," $3 "," $4
}' /tmp/mp3-rows.txt
} > "$CSV_HOST"
log " CSV written: $CSV_HOST ($(wc -l < "$CSV_HOST") lines incl. header)"
if [[ $CSV_ONLY -eq 1 ]]; then
log "--csv-only flag — exiting before sldl run"
exit 0
fi
# Pull credentials from any playlist conf (they're all the same)
SOULSEEK_USER=$(sed -n 's/^user *= *//p' ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/y2k.conf | tr -d ' ')
SOULSEEK_PASS=$(sed -n 's/^pass *= *//p' ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/y2k.conf | tr -d ' ')
log "Running sldl with --format flac (HARD requirement, no MP3/WAV fallback)"
"$SLDL_BIN" \
"$CSV_HOST" \
--input-type csv \
--user "$SOULSEEK_USER" \
--pass "$SOULSEEK_PASS" \
--path "$STAGING_HOST" \
--format flac \
--pref-min-bitrate 320 \
--concurrent-downloads 8 \
--search-timeout 30000 \
--strict-title \
--strict-artist \
--no-browse-folder \
--no-skip-existing \
--verbose \
>> "$LOG" 2>&1
SLDL_EXIT=$?
log "sldl exit code: $SLDL_EXIT"
# Guard: this script is FLAC-upgrade-only. If sldl ever drops anything else
# in staging (WAV/MP3/etc.), purge it so the import-leftover fallback in
# replace-with-better.sh doesn't pull non-FLACs into the library.
NON_FLAC=$(find "$STAGING_HOST" -type f ! -iname "*.flac" ! -name "*.incomplete" 2>/dev/null)
if [[ -n "$NON_FLAC" ]]; then
log "Purging non-FLAC files from staging:"
echo "$NON_FLAC" | tee -a "$LOG"
echo "$NON_FLAC" | xargs -r rm -f
fi
NEW_FLAC_COUNT=$(find "$STAGING_HOST" -type f -iname "*.flac" 2>/dev/null | wc -l)
log "Staging has $NEW_FLAC_COUNT FLAC file(s) ready for upgrade"
if [[ "$NEW_FLAC_COUNT" -eq 0 ]]; then
log "Nothing to upgrade. Done."
exit 0
fi
# === Treat staging files like new playlist downloads ===
# Same hygiene + prep as run-playlist.sh, applied to /sldl-dropbox/_upgrade
# before any of these files enter the library.
# Quarantine FLACs with missing ARTIST/TITLE — same rule as playlist runs.
# Prevents the __.wav-style "untagged files in /Library/ root" failure mode.
QUARANTINED=$(quarantine_untagged "$STAGING_HOST" "$QUARANTINE_DIR" "$LOG" || echo 0)
[[ "$QUARANTINED" -gt 0 ]] && log "Quarantined $QUARANTINED file(s) to $QUARANTINE_DIR"
# Recount post-quarantine
NEW_FLAC_COUNT=$(find "$STAGING_HOST" -type f -iname "*.flac" 2>/dev/null | wc -l)
[[ "$NEW_FLAC_COUNT" -eq 0 ]] && { log "Nothing left after quarantine. Done."; exit 0; }
# Strip MB IDs (prevents Navidrome album fragmentation). Scoped to staging:
# the library-wide strip-mb-tags.sh runs weekly anyway and wouldn't touch
# staging FLACs (they aren't in /Library/ yet).
MB_STRIPPED=$(strip_mb_tags "$STAGING_HOST" "$LOG" || echo 0)
log "Stripped MB tags from $MB_STRIPPED file(s) in staging"
# ALBUMARTIST fallback — safety net for no-match leftovers whose uploader
# didn't set ALBUMARTIST. For matched files this is overwritten by the
# tag-copy step inside replace-with-better.sh (canonical MP3 tags win).
AA_SET=$(set_albumartist_fallback "$STAGING_HOST" "$LOG" || echo 0)
[[ "$AA_SET" -gt 0 ]] && log "Set ALBUMARTIST fallback on $AA_SET file(s)"
# ReplayGain + autocue tagging — same as run-playlist.sh.
log "Pre-tagging with ReplayGain + autocue"
PREPPED=$(prep_audio "$STAGING_HOST" "$LOG" || echo 0)
log "Pre-tagged $PREPPED file(s)"
# Use replace-with-better.sh against the staging dir. --copy-tags-from-existing
# makes it copy canonical tags (incl. GROUPING) from each matched MP3 onto
# the replacing FLAC before the swap, so playlist M3Us still resolve and the
# FLAC inherits spotify-retag's canonical metadata.
log "Running replace-with-better.sh on staging (--copy-tags-from-existing)"
${PIPELINE_DIR:-/app/pipeline}/lib/replace-with-better.sh --apply --copy-tags-from-existing "$STAGING_HOST" >> "$LOG" 2>&1
RBE_EXIT=$?
log "replace-with-better exit: $RBE_EXIT"
# Regenerate playlist M3Us. Upgraded tracks have new paths (FLAC vs MP3) but
# their GROUPING tags were carried over via --copy-tags-from-existing, so the
# beet-driven regen below resolves them to the new FLAC paths.
log "Regenerating playlist M3Us"
PLAYLISTS_DIR=${MUSIC_DATA_DIR:-/data/music}/playlists
mkdir -p "$PLAYLISTS_DIR"
M3U_COUNT=0
while IFS= read -r playlist; do
[[ -z "$playlist" ]] && continue
M3U_OUT="${PLAYLISTS_DIR}/${playlist}.m3u8"
BEET_OUTPUT=$(beet ls -f '$path' "grouping:${playlist}" 2>>"$LOG" || true)
TRACK_COUNT=$(echo -n "$BEET_OUTPUT" | grep -c '^')
[[ "$TRACK_COUNT" -gt 0 ]] || continue
{ echo "#EXTM3U"; echo "$BEET_OUTPUT"; } > "$M3U_OUT"
log " $playlist.m3u8 — $TRACK_COUNT tracks"
M3U_COUNT=$((M3U_COUNT + 1))
done < <(beet ls -f '$grouping' 2>/dev/null | sort -u | grep -v '^$')
log "Regenerated $M3U_COUNT M3U file(s)"
# Cleanup staging
log "Cleaning up empty staging dir"
find "$STAGING_HOST" -type f -delete 2>>"$LOG" || true
find "$STAGING_HOST" -mindepth 1 -type d -empty -delete 2>>"$LOG" || true
log "=== MP3 upgrade scan done ==="
Executable
BIN
View File
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
# Control-plane app
fastapi
uvicorn[standard]
jinja2
sqlalchemy
apscheduler
authlib
itsdangerous
cryptography
httpx
# Pipeline scripts (migrated from /opt/sldl)
beets
mutagen
numpy
pyacoustid
requests
+132
View File
@@ -0,0 +1,132 @@
-- alembic app database — initial schema.
-- Applied once at startup by app/db.py if schema_version < 1. Plain SQL
-- migrations, not the `alembic` Python migration tool (name collision
-- avoided deliberately — see docs/ARCHITECTURE.md).
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
spotify_url TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT 1,
no_m3u BOOLEAN NOT NULL DEFAULT 0,
cron_expr TEXT,
notes TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS secrets (
id INTEGER PRIMARY KEY,
scope TEXT NOT NULL,
key TEXT NOT NULL,
value_encrypted BLOB NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(scope, key)
);
CREATE TABLE IF NOT EXISTS scheduled_jobs (
id INTEGER PRIMARY KEY,
job_key TEXT UNIQUE NOT NULL,
job_type TEXT NOT NULL,
playlist_id INTEGER REFERENCES playlists(id),
cron_expr TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY,
job_key TEXT NOT NULL,
started_at REAL NOT NULL,
finished_at REAL,
status TEXT NOT NULL,
exit_code INTEGER,
summary TEXT,
log_path TEXT,
triggered_by TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS manual_fix_audit (
id INTEGER PRIMARY KEY,
beets_item_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT NOT NULL,
changed_at REAL NOT NULL,
source TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS manual_imports (
id INTEGER PRIMARY KEY,
source_path TEXT NOT NULL,
playlist_name TEXT,
imported_by TEXT NOT NULL,
imported_at REAL NOT NULL,
status TEXT NOT NULL,
job_run_id INTEGER REFERENCES job_runs(id)
);
CREATE TABLE IF NOT EXISTS dedup_runs (
id INTEGER PRIMARY KEY,
started_at REAL NOT NULL,
finished_at REAL,
mode TEXT NOT NULL,
groups_found INTEGER,
kept INTEGER,
deleted INTEGER,
log_path TEXT
);
CREATE TABLE IF NOT EXISTS dedup_candidates (
id INTEGER PRIMARY KEY,
dedup_run_id INTEGER NOT NULL REFERENCES dedup_runs(id),
pass_name TEXT NOT NULL,
keep_path TEXT NOT NULL,
delete_path TEXT NOT NULL,
delete_size_bytes INTEGER,
confirmed BOOLEAN NOT NULL DEFAULT 0,
confirmed_by TEXT,
confirmed_at REAL,
applied BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS genre_runs (
id INTEGER PRIMARY KEY,
started_at REAL,
finished_at REAL,
mode TEXT,
written INTEGER,
unchanged INTEGER,
no_match INTEGER,
no_meta INTEGER,
log_path TEXT
);
CREATE TABLE IF NOT EXISTS genre_candidates (
id INTEGER PRIMARY KEY,
genre_run_id INTEGER NOT NULL REFERENCES genre_runs(id),
artist TEXT,
title TEXT,
old_genre TEXT,
new_genre TEXT
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
oidc_sub TEXT UNIQUE NOT NULL,
email TEXT,
name TEXT,
last_login_at REAL
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
INSERT INTO schema_version (version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);