Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae4d1c710 | ||
|
|
6a4291f224 | ||
|
|
183a6d5442 | ||
|
|
6d274acc47 | ||
|
|
3344b51fbd | ||
|
|
e82c8b2d06 | ||
|
|
f7083f175d | ||
|
|
75b04dc313 | ||
|
|
2625ad1083 | ||
|
|
9c25b1cccb | ||
|
|
7aa32d1b64 | ||
|
|
576c3421b8 | ||
|
|
e4656cfd7a | ||
|
|
acab4cef22 | ||
|
|
04802b3e48 | ||
|
|
44e55ad172 | ||
|
|
132805a25a | ||
|
|
2406641bc6 | ||
|
|
dbcf8a2c35 | ||
|
|
a5a9c04da2 | ||
|
|
c95ae20bfb | ||
|
|
a0dfa61623 | ||
|
|
4b67c3c4d8 | ||
|
|
7ee831adee | ||
|
|
afad21dda5 | ||
|
|
f430e2264d | ||
|
|
82a2183d40 | ||
|
|
c70f9b3f6d | ||
|
|
32b8901bff | ||
|
|
6d03bbc8e7 | ||
|
|
32b58dd787 | ||
|
|
0a365274b6 | ||
|
|
e531e90278 | ||
|
|
c3ab565d9b | ||
|
|
5dbbfcb8a0 | ||
|
|
02a568fb97 | ||
|
|
1e1527d101 | ||
|
|
9eb85ea1f9 | ||
|
|
96944f0b17 | ||
|
|
33f97cd3b3 | ||
|
|
254fb41fd0 | ||
|
|
3613f69593 | ||
|
|
6bf70c6a64 | ||
|
|
f1b8b60817 | ||
|
|
4ae2bde9ab | ||
|
|
ceb5617a42 | ||
|
|
a7ceda95fd | ||
|
|
a32361ee5e | ||
|
|
ce6b3b1d3b | ||
|
|
382e3eb0b8 | ||
|
|
54a393edab | ||
|
|
a5459b0b1e | ||
|
|
b03931f7ce | ||
|
|
82f6ec55c2 | ||
|
|
536949c18d | ||
|
|
337afac91e | ||
|
|
c0c77590b1 | ||
|
|
441b908937 | ||
|
|
1a1dbac268 | ||
|
|
949d3cb6f4 | ||
|
|
3ce8d62e70 | ||
|
|
38737ab84e | ||
|
|
4437519430 | ||
|
|
3b29e173c2 | ||
|
|
9706f5d2de | ||
|
|
206dc3baf1 | ||
|
|
f5a3badb56 | ||
|
|
3e4b4f24f7 | ||
|
|
86f5903e74 | ||
|
|
bd4ee78811 | ||
|
|
b7b39cdc92 | ||
|
|
09532e5216 | ||
|
|
e1786e7cf2 | ||
|
|
409a8d8b0c | ||
|
|
e83162a2ac | ||
|
|
5cd9a0c4c2 | ||
|
|
a803aa046a | ||
|
|
73f77a13c3 | ||
|
|
dd76fcf5d4 | ||
|
|
1cb28095a7 | ||
|
|
e1760e3316 | ||
|
|
272e06daf6 | ||
|
|
4db985b90d | ||
|
|
44d72b9e78 | ||
|
|
8073d0aa9d | ||
|
|
5c678b804c | ||
|
|
81e0995f7d | ||
|
|
167e6d6de3 | ||
|
|
608b0058e3 | ||
|
|
4043798053 | ||
|
|
521537a34c | ||
|
|
19fdffe8bd | ||
|
|
54a4a4180a | ||
|
|
f7aec7f154 | ||
|
|
299078eaba | ||
|
|
7b2b5b66fe | ||
|
|
e1d4d59773 | ||
|
|
5787c86954 | ||
|
|
acbb539b97 | ||
|
|
49a83ef1d5 | ||
|
|
d012654fc9 | ||
|
|
b45771e87e | ||
|
|
011ceeb344 | ||
|
|
88a9bc02be | ||
|
|
6ac544a7b6 | ||
|
|
33396d2daa | ||
|
|
bdc0613739 | ||
|
|
3e32cb9cd1 | ||
|
|
6e80cd44f0 | ||
|
|
3a04e34414 | ||
|
|
9085a80bd1 | ||
|
|
58184008dd | ||
|
|
b4a1e6069e | ||
|
|
9f997f0841 | ||
|
|
f14efdce1a | ||
|
|
92a412e99d | ||
|
|
da5eb968f9 | ||
|
|
b3052e9243 |
No files matched your search
@@ -1,71 +0,0 @@
|
||||
# Tunables for frank-default-small (Tiel-Coder-35B-A3B UD-Q6_K_XL,
|
||||
# qwen35moe hybrid MoE, Dankserver, RTX 3090).
|
||||
#
|
||||
# Runtime: upstream llama.cpp (latest master, CUDA build with
|
||||
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86) on RTX 3090
|
||||
# 24 GB; Ryzen 7 7700X; 62 GB RAM. Replaced the ik_llama.cpp Ornith
|
||||
# CRACK Q8_0 deployment on 2026-09-01.
|
||||
#
|
||||
# Model: peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF, UD-Q6_K_XL quant
|
||||
# (30 GB). Architecture: qwen35moe — 40 layers, full_attention_interval=4
|
||||
# (10 attention + 30 SSM/Mamba), 256 experts / 8 used, 2 KV heads × 256
|
||||
# dim, embedding 2048, rope dimension_count 64, rope freq_base 10 000 000.
|
||||
# Text-only (no mmproj). Built-in chat template in the GGUF.
|
||||
#
|
||||
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
|
||||
# Non-expert weights (~1.6 GB) fit entirely on the 24 GB card.
|
||||
#
|
||||
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per slot
|
||||
# × 3 parallel) and 10 attention layers, the KV cache is ~2 GB — the
|
||||
# requested 2 GB VRAM buffer.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
|
||||
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
|
||||
# incident). This box is NVIDIA/CUDA, but the rule keeps the env portable.
|
||||
|
||||
# Model file (UD-Q6_K_XL, 30 GB, text-only MoE hybrid).
|
||||
MODEL_DIR=/home/dank/models/tiel-coder-35b
|
||||
MODEL_FILE=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf
|
||||
|
||||
# Upstream llama.cpp llama-server (CUDA build, latest master).
|
||||
# Built with -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86
|
||||
# using the staged CUDA 13.2 toolkit at /home/dank/cuda13-stage.
|
||||
LLAMA_SERVER=/home/dank/llama.cpp/build/bin/llama-server
|
||||
|
||||
ALIAS=frank-default-small
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Total context budget across all parallel slots. 200700 / 3 = 66 900
|
||||
# per slot. The model's context_length is 262 144, so 66 900 is well
|
||||
# within range.
|
||||
CTX_SIZE=200700
|
||||
PARALLEL=3
|
||||
|
||||
# Expert offload: all MoE expert weights pinned to CPU RAM.
|
||||
CPU_MOE=1
|
||||
|
||||
# All non-expert layers on GPU (auto-fit will determine the exact count;
|
||||
# with --cpu-moe the non-expert footprint is ~1.6 GB, leaving ample room).
|
||||
N_GPU_LAYERS=999
|
||||
|
||||
# KV cache: Q8_0 for both K and V. ~2 GB total for 200700 ctx × 3 slots
|
||||
# × 10 attention layers × (2 KV heads × 256 dim × 1 byte) × 2 (K+V).
|
||||
CACHE_TYPE_K=q8_0
|
||||
CACHE_TYPE_V=q8_0
|
||||
|
||||
VERBOSITY=2
|
||||
|
||||
# Prefill batch sizing.
|
||||
BATCH_SIZE=4096
|
||||
UBATCH_SIZE=1024
|
||||
|
||||
# Sampling.
|
||||
TEMPERATURE=1.0
|
||||
TOP_K=20
|
||||
TOP_P=0.95
|
||||
MIN_P=0.0
|
||||
|
||||
# Compute threads lowered so other processes keep a core.
|
||||
THREADS=7
|
||||
THREADS_HTTP=8
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=llama.cpp Tiel-Coder-35B-A3B Q6_K_XL qwen35moe hybrid MoE (dankserver, frank-default-small)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_SMALL_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-small.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-small.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,126 +0,0 @@
|
||||
# Tunables for frank-dragoon (kingjones777/Qwen3.8-Flash-Next-ROCmFP4-
|
||||
# STRIX-GGUF on the kingjones30/ROCmFPX llama.cpp runtime, Dankclaw,
|
||||
# Strix Halo gfx1151, unified memory).
|
||||
#
|
||||
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
|
||||
# qwen4exp-on-rocmfpx-d3ca537 patch applied — the model card's pinned
|
||||
# runtime: ROCmFP4 tensor types AND the qwen4exp architecture in one
|
||||
# tree; stock llama.cpp and charlie's fork alone cannot load this GGUF).
|
||||
# Built per the card recipe:
|
||||
# cmake -B build -DGGML_HIP=ON -DGPU_TARGETS=gfx1151 -DGGML_NATIVE=ON -DCMAKE_BUILD_TYPE=Release
|
||||
# at /home/dank/GitPackages/kingjones-ROCmFPX/build.
|
||||
# Replaced the agentionai ROCmFP4-FAST imatrix deployment (charlie's
|
||||
# build-qwen38 runtime) on 2026-08-30.
|
||||
#
|
||||
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
|
||||
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
|
||||
# at layer 1), Q4_0_ROCMFP4_STRIX (file_type 105), 113.47 GiB single
|
||||
# file. STRIX is the top quality tier: PLE table at Q8_0 (50.66 GiB),
|
||||
# token embeddings + lm head at Q6_K. The PLE table NEVER enters GPU
|
||||
# memory — it streams from the file through the OS page cache. Two hard
|
||||
# card constraints:
|
||||
# 1. mmap MUST stay enabled (default) — --no-mmap forces the 50.66 GiB
|
||||
# table into anonymous memory and the cgroup killer takes the
|
||||
# process with nothing in the server log (only dmesg).
|
||||
# 2. Do NOT pin the PLE tensor to CPU with -ot — measured gen fell
|
||||
# ~23 -> 13.4 tok/s; the kernel streams it better.
|
||||
# NOTE: the card's own build_attn_qsa still calls ggml_fill on the
|
||||
# F16-cast kq mask (the path that aborted our charlie build with
|
||||
# --flash-attn on before scripts/patches/qwen4exp-qsa-f32-mask.patch).
|
||||
# If a clean kingjones-tree build aborts graph build with
|
||||
# GGML_ASSERT(a-type == GGML_TYPE_F32), port that fix (F32 base mask +
|
||||
# cast at the attention call) into this tree and rebuild.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
|
||||
# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as
|
||||
# env vars.
|
||||
|
||||
# kingjones30/ROCmFPX runtime binary (card recipe build).
|
||||
LLAMA_BIN=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server
|
||||
|
||||
# Model package from
|
||||
# https://huggingface.co/kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF
|
||||
# Layout: single unsharded GGUF, text-only (the repo ships no mmproj;
|
||||
# the vision tower below is borrowed from the agentionai FAST package —
|
||||
# same base model, f16 — kept only if it loads clean, drop
|
||||
# MMPROJ_PATH if it does not).
|
||||
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX
|
||||
MODEL_FILE=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf
|
||||
MMPROJ_PATH=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf
|
||||
|
||||
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The
|
||||
# Sharp template drives thinking on by default (medium effort); the server
|
||||
# emits reasoning in DeepSeek format so clients receive reasoning_content
|
||||
# separately instead of literal thinking tags in content.
|
||||
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
|
||||
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true,"preserve_reasoning":true}'
|
||||
|
||||
# Alias + network.
|
||||
ALIAS=frank-dragoon
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Serving: 401408-token context, TWO slots (parallel 2), batch 4096 /
|
||||
# ubatch 512, 16+16 threads, f16 KV, 16 context checkpoints, 8 GiB
|
||||
# prompt cache. --fit off: auto-fit segfaults on this model's
|
||||
# hyper-connection/indexer config; -ngl 999 puts every layer on the
|
||||
# SVM-backed GPU pool. ubatch 512 matches the card's re-measurement
|
||||
# (-ub 1024/2048/4096 showed no effect; -t 32 vs 16 is noise).
|
||||
#
|
||||
# CTX_SIZE=401408 / PARALLEL=2: carried over from the FAST deployment
|
||||
# where it loaded clean (~74 s, health 200). The card measures the full
|
||||
# 262144 window at ~71.7 GiB GTT at load on the same 128 GB board; QSA
|
||||
# caps KV growth so the extra window is cheap. KV footprint scales
|
||||
# with the 12 attention layers only. Verify load on first STRIX boot.
|
||||
CTX_SIZE=401408
|
||||
PARALLEL=2
|
||||
|
||||
BATCH_SIZE=4096
|
||||
UBATCH_SIZE=512
|
||||
NUM_THREADS=16
|
||||
THREAD_BATCH=16
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
CTX_CHECKPOINTS=16
|
||||
CACHE_RAM=8192
|
||||
SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state
|
||||
SPLIT_MODE=none
|
||||
FIT=off
|
||||
NGL=999
|
||||
FLASH_ATTN=on
|
||||
|
||||
# Sampling (card instruct table, non-thinking).
|
||||
TEMPERATURE=0.7
|
||||
TOP_K=20
|
||||
TOP_P=0.8
|
||||
MIN_P=0.0
|
||||
PRESENCE_PENALTY=1.5
|
||||
|
||||
# Reasoning on (Sharp template drives it; preserve_thinking + preserve_reasoning
|
||||
# keep the reasoning trail in the conversation). DeepSeek format projects the
|
||||
# reasoning into the API's reasoning_content field instead of literal thinking
|
||||
# tags.
|
||||
REASONING=on
|
||||
REASONING_FORMAT=deepseek
|
||||
|
||||
# Strix Halo unified memory + native gfx1151.
|
||||
HSA_OVERRIDE_GFX_VERSION=11.5.1
|
||||
|
||||
VERBOSITY=4
|
||||
|
||||
|
||||
# Speculative decoding: disabled. The card's Usage block ships no spec
|
||||
# flags. The Flash-Next MTP heads do not load on this loader (requires
|
||||
# per-layer blk.N.hc_attn_norm; every head carries only blk.48 —
|
||||
# verified 3x on build-qwen38). DRAFT_MODEL_* point at the retired
|
||||
# agentionai FAST head and are unused while SPEC_TYPE=none.
|
||||
DRAFT_MODEL_DIR=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST
|
||||
DRAFT_MODEL_FILE=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf
|
||||
SPEC_TYPE=none
|
||||
SPEC_DRAFT_N_MAX=3
|
||||
SPEC_DRAFT_N_MIN=0
|
||||
SPEC_DRAFT_P_MIN=0.75
|
||||
SPEC_DRAFT_NGL=999
|
||||
SPEC_DRAFT_DEVICE=ROCm0
|
||||
SPEC_DRAFT_TYPE_K=f16
|
||||
SPEC_DRAFT_TYPE_V=f16
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=ROCmFPX (kingjones30 fork) llama.cpp Qwen3.8-Flash-Next ROCmFP4 STRIX 177B MoE (dankclaw, frank-dragoon)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,23 +0,0 @@
|
||||
# Tunables for frank-npu (FastFlowLM NPU endpoint, Dankclaw)
|
||||
# Runtime: FLM 1.0.1 (/usr/bin/flm) on the XDNA2 NPU (50 TOPS). Boot tag is
|
||||
# nuextract3:4b (NuExtract3-4B-NPU2, Qwen3.5-4B skeleton, structured-extraction
|
||||
# model); serve routes every request by the exact FLM registry tag in the
|
||||
# request's model field, so any registered tag (gemma4-it:e4b, qwythos:9b, ...)
|
||||
# is reachable on this port regardless of the boot tag. The NPU is a
|
||||
# single-lane device: generations serialize FIFO, and the first request after a
|
||||
# model swap pays the ~6-11 s load cost. The NPU draws from the unified RAM
|
||||
# pool shared with frank-dragoon (iGPU).
|
||||
FLM_MODEL=nuextract3:4b
|
||||
FLM_HOST=0.0.0.0
|
||||
FLM_PORT=7771
|
||||
# turbo = max NPU power mode (xrt-smi configure --pmode turbo at serve start).
|
||||
# No other tenant uses the NPU on this host, so the top mode costs nothing to
|
||||
# neighbours; drop to `performance` if iGPU power-budget contention ever shows.
|
||||
FLM_PMODE=turbo
|
||||
# FLM_ASR=1 additionally loads Whisper-V3-Turbo-NPU2 (+0.62 GB) and exposes
|
||||
# /v1/audio/transcriptions. NuExtract3 answers text-only; audio stays off.
|
||||
FLM_ASR=0
|
||||
# -1 = no --ctx-len flag: every pack loads with its own trained context
|
||||
# (nuextract3:4b -> 32768, gemma4-it:e4b -> 131072). A global override would
|
||||
# over-allocate KV for the small packs.
|
||||
FLM_CTX_LEN=-1
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=FastFlowLM NPU sight+sound inference (frank-npu) on Dankclaw
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-npu.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-npu.sh
|
||||
LimitMEMLOCK=infinity
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,180 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF as
|
||||
# frank-dragoon via the kingjones30/ROCmFPX llama.cpp runtime on
|
||||
# Dankclaw (Strix Halo gfx1151, unified memory).
|
||||
#
|
||||
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
|
||||
# model card's qwen4exp-on-rocmfpx patch) built at
|
||||
# /home/dank/GitPackages/kingjones-ROCmFPX/build (HIP, gfx1151,
|
||||
# ROCm 7.2.4, card recipe). The ROCmFP4 quant types plus the qwen4exp
|
||||
# architecture must be in one tree; stock llama.cpp cannot load this
|
||||
# GGUF.
|
||||
#
|
||||
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
|
||||
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
|
||||
# at layer 1) quantized Q4_0_ROCMFP4_STRIX, 113.47 GiB single file. The
|
||||
# 50.66 GiB PLE n-gram table stays OUT of GPU memory and streams via
|
||||
# mmap from the OS page cache — never add --no-mmap or -ot for it.
|
||||
#
|
||||
# Profile: card sampling table (temp 0.7, top_p 0.80, top_k 20, min_p 0.0,
|
||||
# presence 1.5 — non-thinking instruct) mixed with the validated serving
|
||||
# profile: ctx 401408 x 2 slots, b 4096 / ub 512, t 16 / tb 16, f16 KV,
|
||||
# fa on (if the graph build aborts on the QSA F16 mask, see the note in
|
||||
# the env file: port scripts/patches/qwen4exp-qsa-f32-mask.patch into
|
||||
# this tree and rebuild), cont-batching, prompt cache + 8 GiB cache-ram
|
||||
# + idle-slot cache, 16 ctx checkpoints, --fit off (auto-fit segfaults
|
||||
# on qwen4exp hyper-connection config). Speculative decoding off: the
|
||||
# Flash-Next MTP heads do not load on this loader; the card ships no
|
||||
# spec flags.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}"
|
||||
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}"
|
||||
: "${MMPROJ_PATH:=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf}"
|
||||
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={}}"
|
||||
: "${ALIAS:=frank-dragoon}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
: "${CTX_SIZE:=401408}"
|
||||
: "${PARALLEL:=2}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=16}"
|
||||
: "${THREAD_BATCH:=16}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${CTX_CHECKPOINTS:=16}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}"
|
||||
: "${SPLIT_MODE:=none}"
|
||||
: "${FIT:=off}"
|
||||
: "${NGL:=999}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${TOP_P:=0.8}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${PRESENCE_PENALTY:=1.5}"
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${HSA_OVERRIDE_GFX_VERSION:=11.5.1}"
|
||||
: "${SPEC_TYPE:=none}"
|
||||
: "${DRAFT_MODEL_DIR:=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST}"
|
||||
: "${DRAFT_MODEL_FILE:=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf}"
|
||||
: "${SPEC_DRAFT_N_MAX:=3}"
|
||||
: "${SPEC_DRAFT_N_MIN:=0}"
|
||||
: "${SPEC_DRAFT_P_MIN:=0.75}"
|
||||
: "${SPEC_DRAFT_NGL:=999}"
|
||||
: "${SPEC_DRAFT_DEVICE:=ROCm0}"
|
||||
: "${SPEC_DRAFT_TYPE_K:=f16}"
|
||||
: "${SPEC_DRAFT_TYPE_V:=f16}"
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
for f in "$LLAMA_BIN" "$MODEL" "$CHAT_TEMPLATE_FILE"; do
|
||||
if [[ ! -r "$f" ]]; then
|
||||
echo "missing or unreadable required artifact: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ ! -x "$LLAMA_BIN" ]]; then
|
||||
echo "missing executable runtime: $LLAMA_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- speculative decoding (MTP draft head) ---------------------------------
|
||||
# The MTP draft head is a separate GGUF sidecar loaded via --spec-draft-model
|
||||
# (alias --model-draft). ngram-only specs do not need a draft file.
|
||||
# Set SPEC_TYPE=none in the env to disable speculative decoding entirely.
|
||||
SPEC_ARGS=()
|
||||
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
|
||||
SPEC_ARGS=(
|
||||
--spec-type "$SPEC_TYPE"
|
||||
--spec-draft-n-max "$SPEC_DRAFT_N_MAX"
|
||||
--spec-draft-n-min "$SPEC_DRAFT_N_MIN"
|
||||
--spec-draft-p-min "$SPEC_DRAFT_P_MIN"
|
||||
)
|
||||
if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then
|
||||
DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE"
|
||||
if [[ ! -r "$DRAFT_MODEL" ]]; then
|
||||
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SPEC_ARGS+=(
|
||||
--spec-draft-model "$DRAFT_MODEL"
|
||||
--spec-draft-ngl "$SPEC_DRAFT_NGL"
|
||||
--spec-draft-device "$SPEC_DRAFT_DEVICE"
|
||||
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
|
||||
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
|
||||
)
|
||||
fi
|
||||
if [[ -n "${SPEC_DRAFT_P_SPLIT:-}" ]]; then
|
||||
SPEC_ARGS+=(--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT")
|
||||
fi
|
||||
fi
|
||||
|
||||
# The STRIX repo ships no mmproj; this is the agentionai FAST package's
|
||||
# f16 vision tower for the same base model. Passed only when readable.
|
||||
MMPROJ_ARGS=()
|
||||
if [[ -n "${MMPROJ_PATH:-}" && -r "${MMPROJ_PATH:-}" ]]; then
|
||||
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
|
||||
fi
|
||||
|
||||
SERVER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
mkdir -p "$SLOT_SAVE_PATH"
|
||||
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
|
||||
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
exec /usr/bin/env \
|
||||
ROCM_PATH="$ROCM_PATH" \
|
||||
HIP_VISIBLE_DEVICES=0 \
|
||||
LD_LIBRARY_PATH="$LIBRARY_PATH" \
|
||||
HSA_OVERRIDE_GFX_VERSION="$HSA_OVERRIDE_GFX_VERSION" \
|
||||
"$LLAMA_BIN" \
|
||||
--model "$MODEL" \
|
||||
--alias "$ALIAS" \
|
||||
--chat-template-file "$CHAT_TEMPLATE_FILE" \
|
||||
--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--jinja \
|
||||
--device ROCm0 \
|
||||
--gpu-layers "$NGL" \
|
||||
--split-mode "$SPLIT_MODE" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--fit "$FIT" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--ctx-checkpoints "$CTX_CHECKPOINTS" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--slot-save-path "$SLOT_SAVE_PATH" \
|
||||
--cache-prompt \
|
||||
--cache-idle-slots \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-p "$TOP_P" \
|
||||
--top-k "$TOP_K" \
|
||||
--min-p "$MIN_P" \
|
||||
--presence-penalty "$PRESENCE_PENALTY" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget -1 \
|
||||
"${MMPROJ_ARGS[@]}" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve the FastFlowLM (FLM) sight+sound endpoint — frank-npu.
|
||||
#
|
||||
# Runs `flm serve <tag>` on the XDNA2 NPU (50 TOPS, Dankclaw). The NPU is a
|
||||
# single-lane device: generations serialize in FIFO order, so this is a
|
||||
# background/triage endpoint, not an interactive multi-user chat. The NPU
|
||||
# draws from the unified RAM pool shared with frank-dragoon (the iGPU
|
||||
# llama.cpp service); see serverAppConfigs-docs/LLM/frank-npu.md
|
||||
# for the memory budget and the model-selection tradeoff.
|
||||
#
|
||||
# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity.
|
||||
# NOTE: an unknown model tag does NOT 404 — /v1/chat/completions answers HTTP 200 with a
|
||||
# flat {"error":"Failed to load <tag> model!"} body and no choices; /v1/completions returns a
|
||||
# nested OpenAI-shaped server_error (code 500) also under HTTP 200. Client model IDs must be the exact FLM registry
|
||||
# tag (e.g. gemma4-it:e4b), never a service alias. Custom tags only resolve
|
||||
# when FLM_CONFIG_PATH points at the user registry that carries them and their
|
||||
# kernels are symlinked into /home/dank/.config/flm/xclbins/<PackName>.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_NPU_ENV_FILE:-$SCRIPT_DIR/frank-npu.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${FLM_BIN:=/usr/bin/flm}"
|
||||
: "${FLM_MODEL:=gemma4-it:e4b}"
|
||||
: "${FLM_HOST:=0.0.0.0}"
|
||||
: "${FLM_PORT:=7771}"
|
||||
: "${FLM_PMODE:=turbo}"
|
||||
: "${FLM_ASR:=0}"
|
||||
: "${FLM_CTX_LEN:=131072}"
|
||||
|
||||
# The user registry is a superset of the system registry (it carries the
|
||||
# custom tags too), and its model_path resolves relative to the registry file,
|
||||
# so exports keep a tag swap in the env file from needing script edits.
|
||||
export FLM_CONFIG_PATH="${FLM_CONFIG_PATH:-/home/dank/.config/flm/model_list.json}"
|
||||
export FLM_XCLBIN_PATH="${FLM_XCLBIN_PATH:-/home/dank/.config/flm/xclbins}"
|
||||
|
||||
if [[ ! -x "$FLM_BIN" ]]; then
|
||||
echo "missing flm binary: $FLM_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- optional flags ----------------------------------------------------------
|
||||
OPT_ARGS=()
|
||||
if [[ "${FLM_ASR}" != "0" ]]; then
|
||||
OPT_ARGS+=(--asr "${FLM_ASR}")
|
||||
fi
|
||||
if [[ "${FLM_CTX_LEN}" != "-1" ]]; then
|
||||
OPT_ARGS+=(--ctx-len "${FLM_CTX_LEN}")
|
||||
fi
|
||||
|
||||
exec "$FLM_BIN" serve "$FLM_MODEL" \
|
||||
--host "$FLM_HOST" \
|
||||
-p "$FLM_PORT" \
|
||||
--pmode "$FLM_PMODE" \
|
||||
"${OPT_ARGS[@]}"
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve peculiar-ragdoll/Tiel-Coder-35B-A3B (UD-Q6_K_XL) as
|
||||
# frank-default-small via stock llama.cpp on Dankserver (RTX 3090).
|
||||
#
|
||||
# Runtime: upstream llama.cpp (latest master, CUDA build with
|
||||
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86).
|
||||
# The model is a 35B-total / 8-active MoE hybrid (qwen35moe arch) with
|
||||
# SSM/Mamba layers (full_attention_interval=4: 10 attention + 30 SSM
|
||||
# layers out of 40). 256 experts, 8 used per token.
|
||||
#
|
||||
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
|
||||
# Non-expert weights (attention, SSM, shared expert, embeddings, output)
|
||||
# are ~1.6 GB and fit entirely on the 24 GB RTX 3090.
|
||||
#
|
||||
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per
|
||||
# slot × 3 parallel) and 10 attention layers (2 KV heads × 256 dim),
|
||||
# the KV cache is ~2 GB — the requested 2 GB VRAM buffer.
|
||||
#
|
||||
# Model is loaded with mmap (default). The 30 GB Q6_K_XL file is
|
||||
# memory-mapped; only accessed pages enter RAM.
|
||||
#
|
||||
# Tunables come from frank-default-small.env unless FRANK_SMALL_ENV_FILE
|
||||
# overrides that path.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_SMALL_ENV_FILE:-$SCRIPT_DIR/frank-default-small.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -----------------
|
||||
: "${MODEL_DIR:=/home/dank/models/tiel-coder-35b}"
|
||||
: "${MODEL_FILE:=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf}"
|
||||
: "${LLAMA_SERVER:=/home/dank/llama.cpp/build/bin/llama-server}"
|
||||
: "${ALIAS:=frank-default-small}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
: "${CTX_SIZE:=200700}"
|
||||
: "${PARALLEL:=3}"
|
||||
: "${CACHE_TYPE_K:=q8_0}"
|
||||
: "${CACHE_TYPE_V:=q8_0}"
|
||||
: "${N_GPU_LAYERS:=999}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=1024}"
|
||||
: "${VERBOSITY:=2}"
|
||||
: "${CPU_MOE:=1}"
|
||||
: "${TEMPERATURE:=1.0}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${TOP_P:=0.95}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${THREADS:=7}"
|
||||
: "${THREADS_HTTP:=8}"
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
|
||||
if [[ ! -f "$MODEL" ]]; then
|
||||
echo "missing required file: $MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$LLAMA_SERVER" ]]; then
|
||||
echo "missing llama-server binary: $LLAMA_SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$LLAMA_SERVER" \
|
||||
--model "$MODEL" \
|
||||
$([ "$CPU_MOE" = "1" ] && printf -- "--cpu-moe") \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--parallel "$PARALLEL" \
|
||||
--gpu-layers "$N_GPU_LAYERS" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-k "$TOP_K" \
|
||||
--top-p "$TOP_P" \
|
||||
--min-p "$MIN_P" \
|
||||
--threads "$THREADS" \
|
||||
--threads-http "$THREADS_HTTP" \
|
||||
--alias "$ALIAS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT"
|
||||
@@ -26,8 +26,8 @@ merge absorbed the former `vllmScripts` repo, history preserved).
|
||||
| Target | Service | Runtime | Payload | Port |
|
||||
|--------|---------|---------|---------|------|
|
||||
| Dankserver | `frank-archon` | radiance, digest-pinned, root unit, TP=2 | publisher `qwen3.8-next-flash-fp8-iq4r-moe.rad` | 7770 |
|
||||
| Dankclaw | `frank-dragoon` | halogen-flash-server 0.16.2, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 |
|
||||
| Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 10777, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj + rank-1 refusal adapter | 7771 |
|
||||
| Dankclaw | `frank-dragoon` | halogen-flash-server 0.18.20, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 |
|
||||
| Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 12118, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj + rank-1 refusal adapter | 7771 |
|
||||
|
||||
Payload identity, shape, eras, laws and rollback live in ONE page each:
|
||||
[[frank-archon]], [[frank-dragoon]], [[frank-zealot]]. `scripts/<service>.env`
|
||||
@@ -156,6 +156,15 @@ Unit requirements:
|
||||
(`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by
|
||||
the quadlet test seam. The host-side unit is generated state; the repo file
|
||||
is the source of truth.
|
||||
- **Bind-source gate** — a unit whose `Volume=` source is a mount this host owns
|
||||
(the entropy array, the metadata view) carries
|
||||
`ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh <source>`.
|
||||
Measured 2026-10-31: in a **rootless** unit `RequiresMountsFor=` is inert (the
|
||||
user manager cannot order against the system's mount unit), and
|
||||
`ConditionPathIsMountPoint=` is fail-*dead* — an unmet condition logs `skipped,
|
||||
unmet condition check` with `NRestarts=0` and no start attempt ever follows.
|
||||
Bind the array before it is mounted and the container sees the empty pre-mount
|
||||
directory as truth; a media scan then deletes the library it cannot see.
|
||||
|
||||
Before declaring a service deployed: `systemctl --user is-enabled <name>` →
|
||||
`enabled`, `is-active` → `active`, and the container answers its health check.
|
||||
@@ -293,8 +302,8 @@ means off or NIC dead; a merely wedged kernel usually still answers ARP.
|
||||
2. Bench against the service itself. No scratch ports on an LLM host — one
|
||||
process owns the card. Use the runtime's own harness (`llama-bench`; a
|
||||
live llama.cpp server also reports honest `timings` per request, including
|
||||
`draft_n`/`draft_n_accepted` — but on frank-zealot's PrismML fork build
|
||||
10777 that object only reaches you on **`/v1/chat/completions`**: a greedy
|
||||
`draft_n`/`draft_n_accepted` — but on frank-zealot's PrismML fork that object
|
||||
only reaches you on **`/v1/chat/completions`** (measured on build 10777): a greedy
|
||||
`ignore_eos` run on the raw `/completion` path there answers **500 "The
|
||||
model produced output that does not match the expected Content-only
|
||||
format"** (measured 2026-10-18). halogen has no `/completion` path at all,
|
||||
@@ -439,16 +448,16 @@ disagrees with it is the bug.
|
||||
### frank-zealot — llama.cpp (PrismML fork), RTX 3090, :7771
|
||||
|
||||
- Owns: `scripts/frank-zealot.env`, `scripts/serve-frank-zealot.sh`,
|
||||
`scripts/frank-zealot.service`; fork build `llama-prism-b10777` is the ONLY
|
||||
loader (the payload carries the fork's custom PQ2_0 tensor type plus
|
||||
`scripts/frank-zealot.service`; fork build `llama-prism-0244744` (build 12118)
|
||||
is the ONLY loader — `llama-prism-b10777` stays on disk as its rollback (the payload carries the fork's custom PQ2_0 tensor type plus
|
||||
`prism.hadamard.*` dequant metadata — stock llama.cpp refuses it, and a
|
||||
loader ignoring that metadata dequantizes wrong); payload
|
||||
`Bonsai2-9B-MTP/…PQ2_0.gguf` + the publisher's **Q8_0** mmproj (the BF16
|
||||
tower can crash the server with concurrent image requests) + the rank-1
|
||||
refusal-direction adapter applied at load by `--lora-scaled …:1`. The
|
||||
adapter's rollback is `LORA_FILE=` (empty) in the env — one line, no rebuild
|
||||
— and build 10777 is also the gate that lets it load: older forks refuse it
|
||||
with `unsupported prism.hadamard.version: 2`.
|
||||
— and the fork BUILD is also the gate that lets it load: anything older than
|
||||
b10777 refuses it with `unsupported prism.hadamard.version: 2`.
|
||||
- Shape: `--ctx-size 602112 --parallel 3` = **three conversations at
|
||||
200,704 each**, `CACHE_TYPE_K=f16` + `CACHE_TYPE_V=q8_0` (asymmetric: 25,088
|
||||
B/token, 14,406 MiB of pool), batch 6144 / ubatch 1024,
|
||||
@@ -741,6 +750,13 @@ linked runbooks.
|
||||
`/etc/default/grub`. It replaced `amd_iommu=off`, and the local A/B on byte-identical traffic
|
||||
says identity mode costs nothing, so the XDNA2 NPU returned for free.
|
||||
[[dankclaw-iommu-translation-off]]
|
||||
- **A deploy handler that only `start`s a quadlet no-ops an image bump.**
|
||||
`systemctl --user start` on an already-active unit does nothing, so installing
|
||||
a new digest under a running service keeps the OLD container serving while the
|
||||
deploy, its health gate and the Playwright suite all pass — the authentik
|
||||
handler did exactly that on 2026-10-30 (2026.8.3 → 2026.8.5 moved only the
|
||||
outpost, the one handler that restarted). A pin change is applied by
|
||||
`restart`, never `start`.
|
||||
- **Rootless Quadlet containers run with `--replace --rm`**: `podman logs` covers only the CURRENT
|
||||
instance; history across recreations lives in `journalctl --user -u <name>.service`. Read
|
||||
rootless storage WITHOUT sudo — it is per-user storage, so `sudo podman logs` finds nothing.
|
||||
@@ -800,6 +816,8 @@ per-attempt page and no per-swap ledger anywhere else.
|
||||
| `frank-tts` — cloned-voice speech, Dankclaw | `serverAppConfigs-docs/Dankclaw/Frank TTS.md` |
|
||||
| Dankclaw kernel posture (`iommu=pt` since 2026-10-18) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
|
||||
| Deployment flow | `serverAppConfigs-docs/Architecture/Deployment Flow.md` |
|
||||
| Movie-library relayout (Jellyfin format, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Relayout.md` |
|
||||
| SSD-backed metadata view (Jellyfin writes, Plex reads, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Metadata View.md` |
|
||||
|
||||
There is no page for a retired endpoint and no change diary. A payload swap
|
||||
appends one row to the live endpoint's **Eras** table; when the endpoint itself
|
||||
@@ -841,5 +859,7 @@ history.
|
||||
| Writing a doc | **One owner per fact** — edit the endpoint/service page; everywhere else links. Never restate a number. |
|
||||
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. |
|
||||
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
|
||||
| Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. |
|
||||
| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Not yet deployed on Dankserver: the live quadlets still bind the array read-only — see [[Media Metadata View]].** |
|
||||
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
|
||||
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
|
||||
@@ -23,7 +23,7 @@ Network=dankclaw_default
|
||||
# Image=ghcr.io/goauthentik/ldap:2026.8.3 (amd64 digest; podman 5.7.0 refuses
|
||||
# the tag's nested manifest list, so pin the single-platform digest).
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
Image=ghcr.io/goauthentik/ldap@sha256:2e7b259eb8c37faac916946ddf48ec681ee9d3a9f724fe8521bf161973f13cf9
|
||||
Image=ghcr.io/goauthentik/ldap:2026.8.5@sha256:4c75bd27918418d2d6fbbfe4581bbd5a199fe38942d24559c4483edd64a1ea87
|
||||
Environment=AUTHENTIK_HOST=https://auth.franksdank.cloud
|
||||
Environment=AUTHENTIK_INSECURE=false
|
||||
Environment=AUTHENTIK_TOKEN=${AUTHENTIK_LDAP_OUTPOST_TOKEN}
|
||||
|
||||
@@ -20,7 +20,7 @@ Wants=authentik-server.service
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-outpost
|
||||
Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
|
||||
Image=ghcr.io/goauthentik/server:2026.8.5@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
Exec=worker
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
|
||||
@@ -14,7 +14,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-postgres
|
||||
Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
Image=docker.io/library/postgres:16-alpine@sha256:0cefaabd882e42bcb2a2f3dc56d3ce4770560ba62fd7f197f1a2e008fd5731ab
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
Environment=POSTGRES_DB=authentik
|
||||
|
||||
@@ -19,7 +19,7 @@ Wants=authentik-postgres.service
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-server
|
||||
Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
|
||||
Image=ghcr.io/goauthentik/server:2026.8.5@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
Exec=server
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
|
||||
@@ -16,7 +16,7 @@ Wants=authentik-server.service
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-worker
|
||||
Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
|
||||
Image=ghcr.io/goauthentik/server:2026.8.5@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
Exec=worker
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
|
||||
@@ -15,7 +15,7 @@ After=network.target
|
||||
[Container]
|
||||
ContainerName=gitea-postgres
|
||||
Network=gitea.network
|
||||
Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
Image=docker.io/library/postgres:16-alpine@sha256:0cefaabd882e42bcb2a2f3dc56d3ce4770560ba62fd7f197f1a2e008fd5731ab
|
||||
EnvironmentFile=/home/dank/Containers/gitea/env
|
||||
Environment=POSTGRES_USER=gitea
|
||||
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
|
||||
|
||||
@@ -25,7 +25,7 @@ Description=Chrome DevTools Protocol daemon (headless-shell) for hermes
|
||||
[Container]
|
||||
ContainerName=chrome-devtools-protocol
|
||||
Network=host
|
||||
Image=docker.io/chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04
|
||||
Image=docker.io/chromedp/headless-shell@sha256:7bee201478b2f6266aae01243740ee85c16281e32f42d3aed9fdf04f4eace7a1
|
||||
Entrypoint=/headless-shell/headless-shell
|
||||
Exec=--no-sandbox --disable-gpu --use-gl=angle --use-angle=swiftshader --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
|
||||
ShmSize=1073741824
|
||||
|
||||
@@ -23,7 +23,7 @@ After=gluetun.service network.target
|
||||
[Container]
|
||||
ContainerName=marreta_browser
|
||||
Network=gluetun.container
|
||||
Image=docker.io/lightpanda/browser:nightly@sha256:979c36ed869f1016b8dcf22939a0633c262f2c30e892a03a9f720eba2ff1931e
|
||||
Image=docker.io/lightpanda/browser:nightly@sha256:e72730e364d1fbf352c1149f29ffa4b278f3b1a55ff0dec3262298ebe624ec7a
|
||||
|
||||
[Service]
|
||||
TimeoutStartSec=300
|
||||
|
||||
@@ -33,7 +33,7 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=alertmanager
|
||||
Image=docker.io/prom/alertmanager:latest@sha256:e9733bafb1bdef9b00e25a21f8f99dc26a22224bf16641ad754d1649f4c3357a
|
||||
Image=docker.io/prom/alertmanager:latest@sha256:39d8bb3966c694a124428a3a88fbcffd40bd069129ecd47c059f8a3f17c55f3b
|
||||
Network=dankclaw_default
|
||||
User=0:0
|
||||
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
|
||||
|
||||
@@ -34,7 +34,7 @@ Wants=network-online.target prometheus.service
|
||||
|
||||
[Container]
|
||||
ContainerName=grafana
|
||||
Image=docker.io/grafana/grafana:latest@sha256:dbbc38ea21103cefb50396340cd88afec779c7e070b0a7bf5a75b4ce46651d3f
|
||||
Image=docker.io/grafana/grafana:latest@sha256:488984b716c0862639f91a698adbe2862677a5a07bad42da5f03f3030883b205
|
||||
Network=dankclaw_default
|
||||
User=0:0
|
||||
PublishPort=127.0.0.1:3002:3000
|
||||
|
||||
@@ -27,7 +27,7 @@ After=network.target
|
||||
[Container]
|
||||
ContainerName=open-webui
|
||||
Network=open-webui.network
|
||||
Image=ghcr.io/open-webui/open-webui:main@sha256:4860ba005ae8f19f08d1e12b0868855b7b339ea0dad9927f196820fc0de04575
|
||||
Image=ghcr.io/open-webui/open-webui:main@sha256:0b399a8ba34558e181d316750455f2a95376a38cd60a61b9c942e138898944bd
|
||||
PublishPort=3005:8080
|
||||
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
|
||||
Environment=ANONYMIZED_TELEMETRY=False
|
||||
|
||||
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
|
||||
[Container]
|
||||
ContainerName=gluetun
|
||||
Network=searxng.network
|
||||
Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
|
||||
Image=docker.io/qmcgaw/gluetun:latest@sha256:4af0c804003481e100180e1f8d4d5e9c48163fedc0f88d061881b615aef9d183
|
||||
AddCapability=NET_ADMIN
|
||||
AddDevice=/dev/net/tun:/dev/net/tun
|
||||
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
|
||||
|
||||
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
|
||||
ContainerName=searxng-valkey
|
||||
Network=searxng.network
|
||||
NetworkAlias=valkey
|
||||
Image=docker.io/valkey/valkey:9-alpine@sha256:48332870af354a799964c0012ae1194a0bf2bf894eb508f945810596dc2d8d11
|
||||
Image=docker.io/valkey/valkey:9-alpine@sha256:bf09d58e96d6b4524a15396b65b242bbe18beebbcc15a04d8ee75ddac62fae0b
|
||||
Volume=/opt/searxng/valkey:/data
|
||||
|
||||
[Service]
|
||||
|
||||
@@ -16,7 +16,7 @@ After=gluetun.service searxng-valkey.service network.target
|
||||
[Container]
|
||||
ContainerName=searxng
|
||||
Network=gluetun.container
|
||||
Image=ghcr.io/searxng/searxng:latest@sha256:5ed0323b5f5f8701264ef65e239a2bd83cb57098f2b971fa30bf79c06eeae271
|
||||
Image=ghcr.io/searxng/searxng:latest@sha256:f4aa660c54d062dedb268be84e821ea24e39f48c7a496af8dd02cc9eedb93847
|
||||
Environment=SEARXNG_SECRET=${SEARXNG_SECRET}
|
||||
Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL}
|
||||
Volume=/opt/searxng/config:/etc/searxng
|
||||
|
||||
@@ -29,7 +29,7 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=seerr
|
||||
Image=ghcr.io/seerr-team/seerr:v3.4.1@sha256:f4768de5f616248d723e05891f3345a1402123775d03bf0890dbfedc0831bda1
|
||||
Image=ghcr.io/seerr-team/seerr:v3.5.1@sha256:d8fab675b55ad293ded98fd71efcfd46054cbad9746534ae1cc4794a88c0b3be
|
||||
Network=dankclaw_default
|
||||
PublishPort=127.0.0.1:5055:5055
|
||||
Volume=/home/dank/Containers/seerr/data:/app/config
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTTP front door for the frank-medium single-GPU replicas.
|
||||
|
||||
Why two replicas instead of TP=2: one vLLM process spanning both R9700s needs
|
||||
RCCL collectives, and RCCL's kernels require PCIe atomics on every
|
||||
participating GPU. The second card sits behind chipset bridges advertising
|
||||
`AtomicOpsCap: Routing-`, which no software can enable (see the runbook), so
|
||||
no collective can launch on it. Two single-GPU replicas use both cards; this
|
||||
service keeps the fleet contract of one endpoint on :7770, streaming included.
|
||||
|
||||
Routing decisions live in routing.py — spread single-turn requests, pin
|
||||
continuations to the replica whose prefix cache holds their context. This file
|
||||
only moves bytes: parse enough of the request to route it, forward it verbatim,
|
||||
stream the answer back, and report replica health on /health.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
|
||||
import routing
|
||||
|
||||
PORT = int(os.environ.get("PORT", "7770"))
|
||||
HEALTH_TTL = float(os.environ.get("HEALTH_TTL", "10"))
|
||||
# Hop-by-hop headers must not be forwarded (RFC 9110 §7.6.1) — forwarding
|
||||
# transfer-encoding or content-length of a rewritten body breaks clients.
|
||||
HOP_BY_HOP = {
|
||||
"host", "content-length", "transfer-encoding", "connection",
|
||||
"keep-alive", "proxy-authenticate", "proxy-authorization", "te",
|
||||
"trailers", "upgrade",
|
||||
}
|
||||
|
||||
UPSTREAMS = routing.UPSTREAMS
|
||||
# Replica health, refreshed by the watcher. Module state rather than aiohttp app
|
||||
# state: the application is frozen once the site is up.
|
||||
_UP: list[bool] = [True] * len(UPSTREAMS)
|
||||
|
||||
|
||||
async def health(session: ClientSession) -> web.Response:
|
||||
results = await asyncio.gather(
|
||||
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
|
||||
return_exceptions=True,
|
||||
)
|
||||
lines, up = [], 0
|
||||
for url, res in zip(UPSTREAMS, results):
|
||||
if isinstance(res, Exception) or res.status != 200:
|
||||
lines.append(f"{url} DOWN")
|
||||
else:
|
||||
up += 1
|
||||
lines.append(f"{url} UP")
|
||||
return web.Response(status=200 if up else 503, text="\n".join(lines) + "\n")
|
||||
|
||||
|
||||
async def watch_health() -> None:
|
||||
"""Refresh the replica up/down table so routing never targets a dead one.
|
||||
|
||||
One HTTP request per replica per interval, in exchange for never handing a
|
||||
client a 502 after a restart.
|
||||
"""
|
||||
global _UP
|
||||
session = ClientSession()
|
||||
try:
|
||||
while True:
|
||||
res = await asyncio.gather(
|
||||
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
|
||||
return_exceptions=True,
|
||||
)
|
||||
_UP = [not isinstance(r, Exception) and r.status == 200 for r in res]
|
||||
await asyncio.sleep(HEALTH_TTL)
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def proxy(request: web.Request) -> web.StreamResponse:
|
||||
session: ClientSession = request.app["session"]
|
||||
if request.path == "/health":
|
||||
return await health(session)
|
||||
|
||||
body = await request.read()
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP}
|
||||
|
||||
# Fail over before anything is written to the client. A replica that
|
||||
# restarts is invisible to the health watcher for up to HEALTH_TTL, and a
|
||||
# deploy restarts both replicas — without this retry a 10 s window of
|
||||
# requests would 502 on a service that has a live card the whole time.
|
||||
# vLLM's OpenAI endpoints are stateless per request, so re-sending a
|
||||
# request that never reached an engine is safe; a request that failed
|
||||
# mid-stream is not retried (that path is below).
|
||||
tried: set[int] = set()
|
||||
upstream = None
|
||||
last_error = "no healthy frank-medium replica"
|
||||
while len(tried) < len(UPSTREAMS):
|
||||
idx = routing.route(body, request.remote, _UP)
|
||||
if idx < 0 or idx in tried:
|
||||
break
|
||||
tried.add(idx)
|
||||
target = UPSTREAMS[idx] + request.path_qs
|
||||
try:
|
||||
upstream = await session.request(
|
||||
request.method, target, headers=headers, data=body or None,
|
||||
timeout=ClientTimeout(total=None, sock_connect=10, sock_read=3600),
|
||||
)
|
||||
break
|
||||
except Exception as exc:
|
||||
# Demote at once: the watcher will confirm, but every request until
|
||||
# then should skip this replica rather than pay the same timeout.
|
||||
# Logged because a silent failover hides a card that is down.
|
||||
_UP[idx] = False
|
||||
last_error = f"{target} unreachable: {exc}"
|
||||
print(f"proxy: failover — {target} unreachable ({exc}); retrying "
|
||||
f"another replica", flush=True)
|
||||
if upstream is None:
|
||||
# 503 when no replica was even reachable through the routing table,
|
||||
# 502 when replicas were tried and all of them failed.
|
||||
status = 502 if tried else 503
|
||||
return web.Response(status=status, text=f"proxy: {last_error}\n")
|
||||
|
||||
out = web.StreamResponse(status=upstream.status, reason=upstream.reason)
|
||||
for k, v in upstream.headers.items():
|
||||
if k.lower() not in HOP_BY_HOP:
|
||||
out.headers[k] = v
|
||||
await out.prepare(request)
|
||||
try:
|
||||
async for chunk in upstream.content.iter_any():
|
||||
await out.write(chunk)
|
||||
except Exception:
|
||||
# The client went away mid-stream (an aborted generation). Nothing to
|
||||
# report: the engine sees its own cancellation.
|
||||
pass
|
||||
finally:
|
||||
upstream.release()
|
||||
with suppress(Exception):
|
||||
await out.write_eof()
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
app = web.Application(client_max_size=1024 ** 3)
|
||||
app["session"] = ClientSession()
|
||||
app.router.add_route("*", "/{tail:.*}", proxy)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, "0.0.0.0", PORT).start()
|
||||
print(f"proxy: :{PORT} -> {', '.join(UPSTREAMS)} routing={routing.ROUTING} "
|
||||
f"key={routing.AFFINITY_BYTES}B known_max={routing.KNOWN_MAX}", flush=True)
|
||||
watcher = asyncio.create_task(watch_health())
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.gather(watcher)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(main())
|
||||
@@ -1,98 +0,0 @@
|
||||
# Quadlet unit: frank-medium replica A (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-a.container
|
||||
# Unit name: frank-medium-a.service (container name: frank-medium-a)
|
||||
#
|
||||
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
|
||||
# tensor-parallel process across both R9700s (see frank-medium.container, kept
|
||||
# as the target). That shape cannot run on this host: RCCL's collective kernels
|
||||
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
|
||||
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
|
||||
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
|
||||
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
|
||||
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
|
||||
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
|
||||
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
|
||||
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
|
||||
# atomics. Result: every collective that touches GPU 2 dies at launch.
|
||||
# Two independent single-GPU replicas use both cards instead; the proxy unit
|
||||
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
|
||||
#
|
||||
# SHAPE. vLLM binds 0.0.0.0:7771 on the host network directly; no PublishPort.
|
||||
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
|
||||
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
|
||||
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
|
||||
# this shape instead of using the 2x7551-32624 pin, which is only valid at
|
||||
# TP=2/262144.
|
||||
#
|
||||
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=0 (the first R9700). The
|
||||
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
|
||||
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
|
||||
# first and HIP indexes into the filtered list, so a single-valued pair
|
||||
# (ROCR=1,HIP=1) leaves an empty device set.
|
||||
#
|
||||
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
|
||||
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
|
||||
[Unit]
|
||||
Description=frank-medium replica A — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #1
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-medium-a
|
||||
Image=localhost/radiance-vllm:0.9.3
|
||||
AddDevice=/dev/kfd
|
||||
AddDevice=/dev/dri
|
||||
GroupAdd=keep-groups
|
||||
# The upstream launcher's exact podman posture (rootless).
|
||||
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
|
||||
Environment=HIP_VISIBLE_DEVICES=0
|
||||
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
|
||||
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
|
||||
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
|
||||
Environment=SERVED_NAMES=frank-medium
|
||||
Environment=PORT=7771
|
||||
Environment=TP=1
|
||||
Environment=MAXSEQS=4
|
||||
Environment=CHUNK=8192
|
||||
Environment=MAXLEN=114688
|
||||
Environment=GPU_UTIL=0.98
|
||||
Environment=KV_MEM=0
|
||||
Environment=SPEC_METHOD=dflash
|
||||
Environment=SPEC=7
|
||||
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
|
||||
Volume=/home/dank/models:/models
|
||||
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
|
||||
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
|
||||
# Compile cache. Shared with the other replica and with the TP=2 target: the
|
||||
# entries are content-addressed, so shapes simply add subdirectories.
|
||||
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
|
||||
# The pinned radiance clone: patches applied at every container start.
|
||||
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
|
||||
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
|
||||
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
|
||||
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
|
||||
# Entrypoint + healthcheck from this checkout, mounted read-only.
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
|
||||
# The entrypoint must be the container command; the image's own entrypoint
|
||||
# (which the patched script execs at the end) must not run unpatched.
|
||||
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
|
||||
# 200 only once the engine serves; during first-start compilation the
|
||||
# container is legitimately unhealthy. HealthCmd is split on spaces into an
|
||||
# argv array by the podman 5.x v1 generator, hence the file-based probe.
|
||||
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
|
||||
HealthInterval=60s
|
||||
HealthTimeout=30s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=1800s
|
||||
Pull=never
|
||||
|
||||
[Service]
|
||||
# First start compiles Triton/inductor kernels (several minutes) before the
|
||||
# engine loads ~20 GiB of weights.
|
||||
TimeoutStartSec=1800
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,98 +0,0 @@
|
||||
# Quadlet unit: frank-medium replica B (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-b.container
|
||||
# Unit name: frank-medium-b.service (container name: frank-medium-a)
|
||||
#
|
||||
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
|
||||
# tensor-parallel process across both R9700s (see frank-medium.container, kept
|
||||
# as the target). That shape cannot run on this host: RCCL's collective kernels
|
||||
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
|
||||
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
|
||||
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
|
||||
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
|
||||
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
|
||||
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
|
||||
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
|
||||
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
|
||||
# atomics. Result: every collective that touches GPU 2 dies at launch.
|
||||
# Two independent single-GPU replicas use both cards instead; the proxy unit
|
||||
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
|
||||
#
|
||||
# SHAPE. vLLM binds 0.0.0.0:7772 on the host network directly; no PublishPort.
|
||||
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
|
||||
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
|
||||
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
|
||||
# this shape instead of using the 2x7551-32624 pin, which is only valid at
|
||||
# TP=2/262144.
|
||||
#
|
||||
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=1 (the second R9700). The
|
||||
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
|
||||
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
|
||||
# first and HIP indexes into the filtered list, so a single-valued pair
|
||||
# (ROCR=1,HIP=1) leaves an empty device set.
|
||||
#
|
||||
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
|
||||
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
|
||||
[Unit]
|
||||
Description=frank-medium replica B — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #2
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-medium-b
|
||||
Image=localhost/radiance-vllm:0.9.3
|
||||
AddDevice=/dev/kfd
|
||||
AddDevice=/dev/dri
|
||||
GroupAdd=keep-groups
|
||||
# The upstream launcher's exact podman posture (rootless).
|
||||
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
|
||||
Environment=HIP_VISIBLE_DEVICES=1
|
||||
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
|
||||
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
|
||||
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
|
||||
Environment=SERVED_NAMES=frank-medium
|
||||
Environment=PORT=7772
|
||||
Environment=TP=1
|
||||
Environment=MAXSEQS=4
|
||||
Environment=CHUNK=8192
|
||||
Environment=MAXLEN=114688
|
||||
Environment=GPU_UTIL=0.98
|
||||
Environment=KV_MEM=0
|
||||
Environment=SPEC_METHOD=dflash
|
||||
Environment=SPEC=7
|
||||
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
|
||||
Volume=/home/dank/models:/models
|
||||
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
|
||||
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
|
||||
# Compile cache. Shared with the other replica and with the TP=2 target: the
|
||||
# entries are content-addressed, so shapes simply add subdirectories.
|
||||
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
|
||||
# The pinned radiance clone: patches applied at every container start.
|
||||
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
|
||||
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
|
||||
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
|
||||
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
|
||||
# Entrypoint + healthcheck from this checkout, mounted read-only.
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
|
||||
# The entrypoint must be the container command; the image's own entrypoint
|
||||
# (which the patched script execs at the end) must not run unpatched.
|
||||
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
|
||||
# 200 only once the engine serves; during first-start compilation the
|
||||
# container is legitimately unhealthy. HealthCmd is split on spaces into an
|
||||
# argv array by the podman 5.x v1 generator, hence the file-based probe.
|
||||
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
|
||||
HealthInterval=60s
|
||||
HealthTimeout=30s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=1800s
|
||||
Pull=never
|
||||
|
||||
[Service]
|
||||
# First start compiles Triton/inductor kernels (several minutes) before the
|
||||
# engine loads ~20 GiB of weights.
|
||||
TimeoutStartSec=1800
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,61 +0,0 @@
|
||||
# Quadlet unit: frank-medium proxy (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-proxy.container
|
||||
# Unit name: frank-medium-proxy.service (container name: frank-medium-proxy)
|
||||
#
|
||||
# WHAT IT IS. A prefix-affinity reverse proxy in front of the two single-GPU
|
||||
# frank-medium replicas (:7771 on R9700 #1, :7772 on R9700 #2). It preserves
|
||||
# the fleet contract: one endpoint on host port 7770 answering as the served
|
||||
# model name frank-medium, OpenAI /v1, streaming included.
|
||||
#
|
||||
# WHY IT EXISTS. TP=2 — one vLLM process spanning both cards — is impossible
|
||||
# on this host (see frank-medium-a.container and the runbook: RCCL's kernels
|
||||
# need PCIe atomics, and the second card's chipset path advertises
|
||||
# AtomicOpsCap: Routing-). Two replicas are how both cards get used.
|
||||
#
|
||||
# ROUTING. Requests are keyed on a hash of their leading 4 KiB of prompt text,
|
||||
# so a conversation family — an agent loop re-sending the same system prompt,
|
||||
# tool list and file context every turn — keeps landing on the same replica,
|
||||
# where --enable-prefix-caching has that prefix warm. Plain round-robin threw
|
||||
# half of those cache hits away. A replica that is down is skipped, and
|
||||
# ROUTING=roundrobin restores the old behaviour for A/B measurement. /health
|
||||
# answers 200 while at least one replica is up and names the ones that are
|
||||
# down. The proxy runs on host networking so it can bind 7770 and reach the
|
||||
# replicas on loopback.
|
||||
#
|
||||
# The script is a few dozen lines of aiohttp, mounted read-only out of
|
||||
# /home/dank/serverAppConfigs (deploy.sh pulls that checkout before dispatch).
|
||||
# It reuses the same image as the replicas: aiohttp is already there and no
|
||||
# extra image has to be pulled on this host.
|
||||
[Unit]
|
||||
Description=frank-medium proxy — prefix-affinity in front of the two single-GPU replicas
|
||||
After=network-online.target
|
||||
Wants=frank-medium-a.service frank-medium-b.service
|
||||
After=frank-medium-a.service frank-medium-b.service
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-medium-proxy
|
||||
Image=localhost/radiance-vllm:0.9.3
|
||||
PodmanArgs=--network=host
|
||||
Environment=PORT=7770
|
||||
Environment=UPSTREAMS=http://127.0.0.1:7771,http://127.0.0.1:7772
|
||||
# The proxy is two files: routing.py holds the replica-selection policy, the
|
||||
# server holds the HTTP plumbing. Both are mounted because the server imports
|
||||
# the policy by name from its own directory (/usr/local/bin is sys.path[0]).
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/routing.py:/usr/local/bin/routing.py:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/affinity-proxy.py:/usr/local/bin/affinity-proxy.py:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
|
||||
PodmanArgs=--entrypoint /usr/local/bin/affinity-proxy.py
|
||||
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
|
||||
HealthInterval=60s
|
||||
HealthTimeout=30s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=60s
|
||||
Pull=never
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Routing policy for the frank-medium proxy: which replica serves a request.
|
||||
|
||||
Kept apart from the HTTP plumbing in affinity-proxy.py so the decisions can be
|
||||
tested without aiohttp, a network or a GPU — the first version of these rules
|
||||
shipped with a bug that only appeared on a continuation request, and a test
|
||||
that loads this module catches that class of mistake directly.
|
||||
|
||||
Two cards, two behaviours, and the workload decides which is right:
|
||||
|
||||
* A **single-turn** request is one member of a batch (a benchmark run, a burst
|
||||
of parallel subagents). Spreading those over both replicas lets both cards
|
||||
prefill at once; measured on this host, a 16-request burst of 15k-token
|
||||
prompts finishes in 18.6 s spread versus 30.1 s pinned.
|
||||
* A **continuation** re-sends a prefix the replica has already prefilled. Pinned
|
||||
to that replica the turn costs a cache lookup; spread, half the turns pay a
|
||||
cold prefill again (measured: 1096 ms versus 2676 ms mean warm-turn TTFT on
|
||||
90k-token contexts).
|
||||
|
||||
ROUTING picks the strategy; the default `adaptive` applies both rules above.
|
||||
`affinity` and `roundrobin` exist so the choice can be re-measured, and are not
|
||||
production settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
|
||||
UPSTREAMS = [u.strip() for u in os.environ.get(
|
||||
"UPSTREAMS", "http://127.0.0.1:7771,http://127.0.0.1:7772").split(",") if u.strip()]
|
||||
ROUTING = os.environ.get("ROUTING", "adaptive").strip().lower()
|
||||
# Leading bytes of prompt text that identify a conversation's shared prefix.
|
||||
# 4 KiB covers a system prompt plus the start of the first user turn, which is
|
||||
# where agent loops keep the text they resend every turn.
|
||||
AFFINITY_BYTES = int(os.environ.get("AFFINITY_BYTES", "4096"))
|
||||
# Prefixes remembered for pinning. Bounded so a long-lived proxy cannot grow
|
||||
# without limit; 4096 conversations is far beyond what this fleet runs at once.
|
||||
KNOWN_MAX = int(os.environ.get("KNOWN_MAX", "4096"))
|
||||
|
||||
_counter = itertools.count()
|
||||
# digest -> replica index, insertion-ordered for O(1) LRU eviction. A prefix is
|
||||
# remembered as soon as any request carries it; only a continuation is pinned
|
||||
# there, because only a continuation can profit from the cache entry.
|
||||
_known: "collections.OrderedDict[bytes, int]" = collections.OrderedDict()
|
||||
|
||||
|
||||
def affinity_key(body: bytes, remote: str | None) -> tuple[bytes, bool]:
|
||||
"""Key a request by its shared prefix, and report whether it continues one.
|
||||
|
||||
Chat and completions bodies both carry their prompt text in JSON, so the
|
||||
leading bytes of that text are the prefix; a body we cannot parse (or an
|
||||
endpoint with no prompt, e.g. /v1/models) falls back to the client address,
|
||||
which still beats nothing for a single-client loop.
|
||||
"""
|
||||
fallback = (remote or "anonymous").encode()
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except Exception:
|
||||
return fallback, False
|
||||
|
||||
text = ""
|
||||
continuation = False
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("prompt"), str):
|
||||
text = payload["prompt"]
|
||||
elif isinstance(payload.get("messages"), list):
|
||||
continuation = len(payload["messages"]) > 1
|
||||
parts = []
|
||||
for msg in payload["messages"]:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
parts.append(content)
|
||||
elif isinstance(content, list): # multimodal content blocks
|
||||
parts.extend(
|
||||
blk.get("text", "") for blk in content
|
||||
if isinstance(blk, dict) and isinstance(blk.get("text"), str)
|
||||
)
|
||||
text = "\n".join(parts)
|
||||
if not text:
|
||||
return fallback, continuation
|
||||
return text[:AFFINITY_BYTES].encode("utf-8", "replace"), continuation
|
||||
|
||||
|
||||
def pick(key: bytes, continuation: bool, up: list[bool]) -> int:
|
||||
"""Replica for a request, or -1 when none is available.
|
||||
|
||||
Down replicas are never selected — a rollout must not send traffic to an
|
||||
engine that is still compiling its graphs — and a pin whose replica is down
|
||||
is re-established on a live one rather than kept pointing at a corpse.
|
||||
"""
|
||||
digest = hashlib.sha1(key).digest()
|
||||
|
||||
if ROUTING == "roundrobin":
|
||||
start = next(_counter) % len(UPSTREAMS)
|
||||
elif ROUTING == "adaptive":
|
||||
entry = _known.get(digest)
|
||||
if continuation and entry is not None and up[entry]:
|
||||
_known.move_to_end(digest)
|
||||
return entry
|
||||
# Anything that is not a continuation spreads by counter — including a
|
||||
# batch of requests that happen to share one prefix. Hashing the prefix
|
||||
# would send every member of such a batch to whichever card the hash
|
||||
# names (measured: 29.8 s for a 16-request burst, against 18.6 s when
|
||||
# spread). The counter is only advanced by these requests, so batch
|
||||
# members alternate across the cards.
|
||||
start = next(_counter) % len(UPSTREAMS)
|
||||
else: # affinity
|
||||
start = int.from_bytes(digest[:8], "big") % len(UPSTREAMS)
|
||||
|
||||
for offset in range(len(UPSTREAMS)):
|
||||
idx = (start + offset) % len(UPSTREAMS)
|
||||
if up[idx]:
|
||||
if ROUTING == "adaptive":
|
||||
_known[digest] = idx
|
||||
_known.move_to_end(digest)
|
||||
if len(_known) > KNOWN_MAX:
|
||||
_known.popitem(last=False)
|
||||
return idx
|
||||
return -1
|
||||
|
||||
|
||||
def route(body: bytes, remote: str | None, up: list[bool]) -> int:
|
||||
"""Convenience wrapper: decode the request body and choose a replica."""
|
||||
return pick(*affinity_key(body, remote), up)
|
||||
@@ -43,7 +43,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=calibre-server
|
||||
Image=lscr.io/linuxserver/calibre:9.15.0@sha256:59e4b0e036e62f228f8bcfae1ade42f47047719885fe454ddbad38e4597b987b
|
||||
Image=lscr.io/linuxserver/calibre:9.17.0@sha256:b198fc03a6abc6be7e251a9958e50e74634b00e447c7b25e92c191c8077ba378
|
||||
Network=pasta
|
||||
EnvironmentFile=/home/dank/Containers/calibre-server/env
|
||||
Environment=PUID=1000
|
||||
|
||||
@@ -16,7 +16,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=calibre-web
|
||||
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402@sha256:802729966faed5e32d671cd4f92ba6196e9e754a6c0f740ce1ee9acd86b10e55
|
||||
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls407@sha256:470b4ed9a7d6ea1475fef17dbcf957cb60880910296d226bd0dca652c05f68ca
|
||||
Network=pasta
|
||||
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
|
||||
Environment=LANG=en_US.UTF-8
|
||||
|
||||
+6
-28
@@ -94,34 +94,12 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Jellyfin — media server (movies, TV, music)
|
||||
# ═══════════════════════════════════════════════
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: jellyfin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:8096/tcp"
|
||||
- "8920:8920/tcp"
|
||||
- "7359:7359/udp"
|
||||
- "1900:1900/udp"
|
||||
environment:
|
||||
- JELLYFIN_PublishedServerUrl=http://192.168.0.235:8096
|
||||
- DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
|
||||
volumes:
|
||||
- /opt/jellyfin/config:/config
|
||||
- /opt/jellyfin/cache:/cache
|
||||
- /mnt/entropy:/data:ro
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
group_add:
|
||||
- "992"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8096/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# Jellyfin left this file when it became a Quadlet: the container is owned by
|
||||
# dankserver/jellyfin/quadlet/jellyfin.container, its data roots are under
|
||||
# /home/dank/Containers/jellyfin, and its library arrives through the SSD
|
||||
# overlay at /var/lib/media-meta/view (scripts/media-view.sh). The block that
|
||||
# was here named /opt/jellyfin/* and a read-only array bind — a second owner for
|
||||
# the container name `jellyfin`, wrong on every path. One owner per container.
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Calibre-Web — e-book web frontend
|
||||
|
||||
@@ -36,6 +36,9 @@ Volume=/home/dank/Containers/copyparty/cfg:/cfg
|
||||
PublishPort=3923:3923
|
||||
|
||||
[Service]
|
||||
# /w is the array: serving the pre-mount directory instead of the volume shows an
|
||||
# empty share and nothing complains, so the mount has to answer first.
|
||||
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /mnt/entropy
|
||||
EnvironmentFile=/home/dank/Containers/copyparty/env
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
entropy UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec /etc/entropy nofail,luks
|
||||
@@ -0,0 +1,18 @@
|
||||
# /etc/fstab: static file system information.
|
||||
#
|
||||
# Use 'blkid' to print the universally unique identifier for a
|
||||
# device; this may be used with UUID= as a more robust way to name devices
|
||||
# that works even if disks are added and removed. See fstab(5).
|
||||
#
|
||||
# <file system> <mount point> <type> <options> <dump> <pass>
|
||||
# / was on /dev/nvme0n1p2 during curtin installation
|
||||
/dev/disk/by-uuid/4f3731d2-fb7b-48e7-a79f-0e33cb8a4162 / ext4 defaults 0 1
|
||||
# /boot/efi was on /dev/nvme0n1p1 during curtin installation
|
||||
/dev/disk/by-uuid/183C-442D /boot/efi vfat defaults 0 1
|
||||
#/dev/disk/by-label/archive-one /mnt/archive-one auto nosuid,nodev,nofail,x-gvfs-show 0 0
|
||||
#/dev/disk/by-label/archive-two /mnt/archive-two auto nosuid,nodev,nofail,x-gvfs-show 0 0
|
||||
# /dev/md0 is a LUKS container, not a filesystem; its ext4 is mounted via
|
||||
# /dev/mapper/entropy on the next line. Mounting /dev/md0 directly fails.
|
||||
#/dev/md0 /mnt/md0 ext4 auto,nofail,nosuid,nodev 0 0
|
||||
/dev/mapper/entropy /mnt/entropy ext4 auto,nosuid,nofail,nodev,x-gvfs-show 0 0
|
||||
/swapfile none swap sw 0 0
|
||||
@@ -0,0 +1,30 @@
|
||||
# /etc/systemd/system/var-lib-media\x2dmeta-view.mount
|
||||
# (repo copy: dankserver/host/systemd/media-meta-view.mount — systemd names a
|
||||
# mount unit after its ESCAPED path, so the host file carries the \x2d spelling)
|
||||
#
|
||||
# Installed by `scripts/media-view.sh install` — the repo copy is the source of
|
||||
# truth (fleet law: never edit host state by hand).
|
||||
#
|
||||
# An OverlayFS merge that shows the media library as one tree while splitting
|
||||
# where its bytes live: lowerdir is the RAID6 array (the source of truth, never
|
||||
# written), upperdir is a directory on the NVMe that receives every write. The
|
||||
# two media servers bind the result at /var/lib/media-meta/view, so Jellyfin's
|
||||
# sidecars and artwork are SSD bytes — which is what makes them fast to browse
|
||||
# and what keeps Plex's NFO agent off the spinners — while the media itself stays
|
||||
# on the array untouched.
|
||||
#
|
||||
# RequiresMountsFor is load-bearing: without the array mounted, lowerdir is an
|
||||
# empty directory and the view would present an empty library as truth.
|
||||
|
||||
[Unit]
|
||||
Description=SSD-backed overlay view of the media library (array + NVMe metadata layer)
|
||||
RequiresMountsFor=/mnt/entropy
|
||||
|
||||
[Mount]
|
||||
What=overlay
|
||||
Where=/var/lib/media-meta/view
|
||||
Type=overlay
|
||||
Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work
|
||||
|
||||
[Install]
|
||||
WantedBy=local-fs.target
|
||||
@@ -0,0 +1,14 @@
|
||||
# The io controller is what makes IOWeight= mean anything, and systemd does not
|
||||
# enable it below `user.slice` by default: with io accounting off, `io` never
|
||||
# appears in `user.slice/cgroup.subtree_control`, so no rootless container can
|
||||
# have an `io.weight` file and `IOWeight=` in a quadlet is silently inert.
|
||||
# Measured on Dankserver 2026-10-31: setting this property flipped
|
||||
# `user.slice/cgroup.subtree_control` from `cpu memory pids` to `cpu io memory
|
||||
# pids` immediately, with nothing restarted.
|
||||
#
|
||||
# This is half the fix; the other half is delegating io to user@1000.service
|
||||
# (user@1000.service.d/io-delegate.conf). Both must be in place before a reboot,
|
||||
# because the delegation is read when the user manager starts. Law and the
|
||||
# verification command: [[Quadlet Lifecycle]].
|
||||
[Slice]
|
||||
IOAccounting=yes
|
||||
@@ -0,0 +1,12 @@
|
||||
# The system manager gives the user manager cpu/memory/pids by default (systemd
|
||||
# 259: `systemctl show user@1000.service -p DelegateControllers` → `cpu memory
|
||||
# pids`). Without `io` in that set no `io.weight` file can exist anywhere below
|
||||
# `user.slice`, so `IOWeight=` in a rootless quadlet is silently inert — the same
|
||||
# class of trap as `RequiresMountsFor=`. Granting the controller here is what
|
||||
# makes the media servers' I/O share real.
|
||||
#
|
||||
# Inert until `user@1000.service` starts again, i.e. the next reboot (restarting
|
||||
# it would kill every rootless container on the host). Verify after the reboot
|
||||
# with the check in [[Quadlet Lifecycle]].
|
||||
[Service]
|
||||
DelegateControllers=cpu memory pids io
|
||||
@@ -4,8 +4,9 @@
|
||||
# Unit name: jellyfin.service (podman 5.x drops the container- prefix)
|
||||
#
|
||||
# Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at
|
||||
# cutover. All of /mnt/entropy is mounted read-only at /data: nothing automated
|
||||
# anywhere writes to the entropy volume. Every
|
||||
# cutover. The library is seen through /var/lib/media-meta/view, the SSD-backed
|
||||
# overlay view (scripts/media-view.sh): the array stays read-only and untouched,
|
||||
# while the sidecars this server writes land on the NVMe. Every
|
||||
# creation-time environment variable of the compose-era container is kept as
|
||||
# an Environment= line (zero drift); none of them are secrets, so this unit
|
||||
# needs no EnvironmentFile.
|
||||
@@ -16,7 +17,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=jellyfin
|
||||
Image=ghcr.io/jellyfin/jellyfin:latest@sha256:c3efa69a22f2691fc88d6bb035e87a8560e395a6047f062fb381c61c3185e8f5
|
||||
Image=ghcr.io/jellyfin/jellyfin:latest@sha256:fea863d0cd9ec445985d9c75fc6ed64e38a1d2766cf0c250a33d553f4fc1b771
|
||||
Network=dankserver_appnet
|
||||
Environment=DEBIAN_FRONTEND=noninteractive
|
||||
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
|
||||
@@ -41,7 +42,11 @@ PublishPort=7359:7359/udp
|
||||
PublishPort=1900:1900/udp
|
||||
Volume=/home/dank/Containers/jellyfin/config:/config
|
||||
Volume=/home/dank/Containers/jellyfin/cache:/cache
|
||||
Volume=/mnt/entropy:/data:ro
|
||||
# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo /
|
||||
# episode.nfo + artwork) and those bytes belong on the SSD. The gate in [Service]
|
||||
# is the guard: started against a plain, unmounted view directory a scan decides
|
||||
# the library is empty and deletes what it cannot see.
|
||||
Volume=/var/lib/media-meta/view:/data
|
||||
AddDevice=/dev/dri
|
||||
GroupAdd=keep-groups
|
||||
HealthCmd=curl -f http://localhost:8096/health
|
||||
@@ -50,8 +55,19 @@ HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
|
||||
[Service]
|
||||
# Refuse to start until the bind source is a real mount — see the header of
|
||||
# scripts/wait-for-mount.sh for the blind-boot incident this prevents.
|
||||
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# A library scan reads the array for minutes on end. Weighted LOW (default is
|
||||
# 100) so that when the array is contended a scan loses to playback and to every
|
||||
# other container this user runs. Proportional share, not a cap: it changes
|
||||
# nothing while the disk is idle. 10 is a convention — the requirement is only
|
||||
# that it sit far below 100 — and it is inert until user@1000.service is
|
||||
# delegated the io controller ([[Quadlet Lifecycle]]).
|
||||
IOWeight=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -4,19 +4,33 @@ After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329@sha256:8f655bad7dcbc5398a78f70a5af12600b4ed3424f901566ed1eaa6710e9e2334
|
||||
Image=lscr.io/linuxserver/plex:1.43.5.11079-b3b13e506-ls330@sha256:01a53d9c7adcbb3701fbb0a4230c0a37c85453759b6b05a67356c3c7a09de08d
|
||||
ContainerName=plex
|
||||
Network=dankserver_appnet:alias=plex
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
Volume=/var/lib/plexmediaserver:/config
|
||||
Volume=/mnt/entropy:/mnt/entropy:ro
|
||||
# The container path stays /mnt/entropy on purpose: every path Plex recorded in
|
||||
# its own database is written against it, so swapping the HOST source of this bind
|
||||
# to the overlay view re-points Plex at the sidecars without touching its DB.
|
||||
# Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata.
|
||||
Volume=/var/lib/media-meta/view:/mnt/entropy:ro
|
||||
PublishPort=32400:32400
|
||||
|
||||
[Service]
|
||||
# Refuse to start until the bind source is a real mount: Plex blind is how paths
|
||||
# get marked missing in its own database. Both the view and the array under it
|
||||
# must answer — scripts/wait-for-mount.sh names the 2026-10-31 incident.
|
||||
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# See the jellyfin unit for the reasoning: a section scan gets a LOW weight
|
||||
# (default 100) so a walk of 50k files loses the array to playback and to every
|
||||
# other container under this user. Inert until user@1000.service holds the io
|
||||
# controller ([[Quadlet Lifecycle]]).
|
||||
IOWeight=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -14,7 +14,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=romm-mariadb
|
||||
Image=docker.io/library/mariadb:11@sha256:f16d7a344413e519dff518f27bdb221388f84b0d274c97c4fb5f2cb0dc1b34fa
|
||||
Image=docker.io/library/mariadb:11@sha256:c4124d1afa875e47f2892edf26b482fa54f796e2f342d3717a04ca091a22a9bd
|
||||
Network=dankserver_appnet:alias=mariadb
|
||||
EnvironmentFile=/home/dank/Containers/romm/env
|
||||
Environment=MARIADB_DATABASE=romm
|
||||
|
||||
@@ -12,7 +12,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=romm-redis
|
||||
Image=docker.io/library/redis:7-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
|
||||
Image=docker.io/library/redis:7-alpine@sha256:8da19c7399dca900c6822a1cf30f9d2285585e2c4b457ff4095b0f4500054d3f
|
||||
Network=dankserver_appnet:alias=redis
|
||||
Volume=/home/dank/Containers/romm/redis:/data
|
||||
HealthCmd=redis-cli ping
|
||||
|
||||
@@ -18,7 +18,7 @@ After=romm-mariadb.service romm-redis.service network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=romm
|
||||
Image=ghcr.io/rommapp/romm:latest@sha256:03295f95a6290e752641f9553ac4198b0e5b71b879d57f7b2371b98f608feb54
|
||||
Image=ghcr.io/rommapp/romm:latest@sha256:8677fb7b81b1d3db20b849e989a1dadc2a11159e51eb34a2acdcb01102274edf
|
||||
Network=dankserver_appnet
|
||||
EnvironmentFile=/home/dank/Containers/romm/env
|
||||
Environment=DB_HOST=mariadb
|
||||
|
||||
@@ -4,7 +4,7 @@ After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245@sha256:c66d3afa8f7a701a48dd1b1f57b22eca0483d895d670fe11ecff783f6a9bbd73
|
||||
Image=lscr.io/linuxserver/tautulli:v2.18.2-ls250@sha256:529c7eabcc40554c1dc48f33f1f3cc3b1438462d73e0ec2dac8ac0b66aeeb8d4
|
||||
ContainerName=tautulli
|
||||
Network=dankserver_appnet:alias=tautulli
|
||||
Environment=PUID=0
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bench-spec-ab.py — settle "which proposer should this endpoint use?" with data.
|
||||
|
||||
Runs one payload through several speculative-decoding configurations of the same
|
||||
binary, one at a time, with the unit stopped between runs: on a single-GPU host
|
||||
there is no room for two copies of a 29 GiB model, and the benchmarking policy
|
||||
applies. Every configuration gets identical prompts at temperature 0, and the
|
||||
greedy text is compared across them — speculation may buy speed, never change
|
||||
the answer.
|
||||
|
||||
bench-spec-ab.py <env-file> <serve-script> [options]
|
||||
bench-spec-ab.py /home/dank/serverAppConfigs/scripts/frank-medium.env \
|
||||
/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh \
|
||||
--configs "dflash=draft-dflash@7;ngram=ngram-mod@64;chain=draft-dflash,ngram-mod@7"
|
||||
|
||||
Config syntax: `name=SPEC_TYPE[@n_max][|ENV=VAL|ENV=VAL]`, entries joined by `;`.
|
||||
The systemd unit is restored in a finally block, so it comes back on whatever the
|
||||
env file says even if a configuration crashes the harness.
|
||||
|
||||
THREE SHAPES, because a single one answers the wrong question. A proposer can
|
||||
only draft text it has seen, so the shape decides the verdict:
|
||||
|
||||
prose a 1k prompt asking for one sentence. Nothing to copy and nothing
|
||||
repetitive, so only a model-backed proposer can help: this is the
|
||||
shape that compares drafters.
|
||||
echo a ~1.5k passage of unique lines, "repeat it back verbatim". The
|
||||
output IS the prompt, which is ngram-mod's home turf.
|
||||
structured "25 JSON records, one per line" — repetitive OUTPUT with no prompt
|
||||
overlap, which is what an ngram matcher is really for in an
|
||||
agentic fleet (config edits, tables, record lists).
|
||||
|
||||
echo and structured disable thinking (`chat_template_kwargs`), otherwise the
|
||||
model thinks the answer into `reasoning_content`, `content` comes back empty and
|
||||
the cross-configuration identity check passes vacuously — which is exactly how
|
||||
this harness first "verified" an echo it never looked at.
|
||||
|
||||
Results on Dankserver (frank-medium payload, 2026-09-16, temperature 0). Decode:
|
||||
|
||||
shape dflash=7 ngram-mod(48/64) chain (tok/s)
|
||||
prose 1k/512 43.8 17.7 (0 drafts) 43.7
|
||||
echo 1.5k/400 76.8 102.5 200.6
|
||||
structured/400 91.1 17.8 (0 drafts) 91.2
|
||||
identity 70.4 17.8 (0 drafts) 70.4
|
||||
|
||||
`draft-mtp,draft-dflash` fails at load (a chain has ONE draft context and MTP
|
||||
builds against whatever model that context holds); ngram-mod has no such
|
||||
constraint — it is draftless and joins the chain unconditionally.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
FILLER = ("The quick brown fox jumps over the lazy dog near the river bank. "
|
||||
"A cache is a component that stores data so future requests are faster. ")
|
||||
TOKENS_PER_REP = 28
|
||||
UNIT = "frank-medium.service"
|
||||
MODEL = "frank-medium"
|
||||
SPEC_GREP = ("adding speculative implementation|- n_max=|- block_size=|creating MTP|"
|
||||
"draft acceptance")
|
||||
|
||||
DEFAULT_CONFIGS = [
|
||||
("none", {"SPEC_TYPE": "none"}),
|
||||
("mtp-n3", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "3"}),
|
||||
("mtp-n7", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "7"}),
|
||||
("dflash-n7", {"SPEC_TYPE": "draft-dflash", "SPEC_DRAFT_N_MAX": "7"}),
|
||||
]
|
||||
|
||||
WORDS = ["latency", "throughput", "cache", "adapter", "scheduler", "allocator",
|
||||
"queue", "kernel", "buffer", "shard", "replica", "window"]
|
||||
|
||||
|
||||
def echo_passage(lines=90):
|
||||
"""Unique lines: no long span repeats inside the prompt, so an n-gram can
|
||||
only fire when the OUTPUT copies the INPUT."""
|
||||
return "\n".join(
|
||||
"Record %03d: the %s of subsystem %d reports state %d with checksum %05d."
|
||||
% (i, WORDS[i % len(WORDS)], i % 7, i % 3, (i * 7919) % 100000)
|
||||
for i in range(lines))
|
||||
|
||||
|
||||
ECHO = (echo_passage() + "\n\nRepeat the passage above verbatim, every line, exactly as "
|
||||
"written, with no commentary before or after it.")
|
||||
STRUCTURED = ("Output exactly 25 JSON records, one per line, no prose and no code fence. "
|
||||
'Each line must look like {"id": N, "state": "active", "region": "us-west-2"} '
|
||||
"with N running from 1 to 25.")
|
||||
PROSE = FILLER * (1000 // TOKENS_PER_REP) + "\n\nReply with a single short sentence."
|
||||
IDENTITY = "List the first five prime numbers, comma separated, then stop."
|
||||
NO_THINK = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
def log(msg, logfile):
|
||||
line = "[" + time.strftime("%H:%M:%S") + "] " + str(msg)
|
||||
print(line, flush=True)
|
||||
with open(logfile, "a") as fh:
|
||||
fh.write(line + "\n")
|
||||
|
||||
|
||||
def sh(cmd):
|
||||
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def parse_configs(spec):
|
||||
"""`name=SPEC_TYPE[@n_max][|KEY=VALUE]...`, entries joined by ';'.
|
||||
|
||||
KEY=VALUE parts override any env file key, which is how an impl's own window
|
||||
(SPEC_NGRAM_N_MIN/MAX, for instance) gets swept without hand-editing env
|
||||
files."""
|
||||
out = []
|
||||
for chunk in spec.split(";"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
name, _, rest = chunk.partition("=")
|
||||
over = {}
|
||||
if "|" in rest:
|
||||
rest, _, extras = rest.partition("|")
|
||||
for item in extras.split("|"):
|
||||
if "=" in item:
|
||||
k, _, v = item.partition("=")
|
||||
over[k.strip()] = v.strip()
|
||||
n_max = None
|
||||
if "@" in rest:
|
||||
rest, _, n_max = rest.partition("@")
|
||||
over["SPEC_TYPE"] = rest.strip()
|
||||
if n_max:
|
||||
over["SPEC_DRAFT_N_MAX"] = n_max.strip()
|
||||
out.append((name.strip(), over))
|
||||
return out
|
||||
|
||||
|
||||
def write_env(src, name, overrides):
|
||||
"""The env file with this configuration's overrides applied."""
|
||||
path = "/tmp/spec-ab-" + name.replace("/", "_") + ".env"
|
||||
with open(src) as fh:
|
||||
lines = fh.read().splitlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
for k, v in overrides.items():
|
||||
if line.startswith(k + "="):
|
||||
line = k + "=" + v
|
||||
break
|
||||
out.append(line)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("\n".join(out) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def post(base, body, timeout=1800):
|
||||
req = urllib.request.Request(
|
||||
base + "/v1/chat/completions", data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def vram():
|
||||
out = sh("rocm-smi --showmeminfo vram 2>/dev/null | grep 'Used Memory'")
|
||||
return [round(int(x) / 1e9, 2) for x in re.findall(r":\s+(\d+)\s*$", out, re.M)][:2]
|
||||
|
||||
|
||||
def wait_health(base, timeout=420):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
if not sh("pgrep -x llama-server >/dev/null && echo up"):
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/health", timeout=5) as r:
|
||||
if r.status == 200 and json.load(r).get("status") == "ok":
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(3)
|
||||
return False
|
||||
|
||||
|
||||
def stop_unit():
|
||||
sh("sudo systemctl stop " + UNIT)
|
||||
sh("pkill -x llama-server")
|
||||
for _ in range(40):
|
||||
if not sh("pgrep -x llama-server || true"):
|
||||
return
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def restore_unit(base, logfile):
|
||||
"""Bring the endpoint back on the deployed configuration. Runs in a
|
||||
finally, because a harness that can leave production down is a hazard."""
|
||||
log("restoring " + UNIT, logfile)
|
||||
sh("sudo systemctl start " + UNIT)
|
||||
for _ in range(120):
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/health", timeout=5) as r:
|
||||
if json.load(r).get("status") == "ok":
|
||||
log(UNIT + " healthy again", logfile)
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(5)
|
||||
log("WARNING: " + UNIT + " did not come back healthy -- check it by hand", logfile)
|
||||
return False
|
||||
|
||||
|
||||
def run_shape(base, prompt, max_tokens, label, extra=None):
|
||||
body = {"model": MODEL, "messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens, "temperature": 0.0}
|
||||
if extra:
|
||||
body.update(extra)
|
||||
d = post(base, body)
|
||||
tm = d["timings"]
|
||||
msg = d["choices"][0]["message"]
|
||||
content = (msg.get("content") or "").strip()
|
||||
row = {"shape": label,
|
||||
"prompt_n": tm["prompt_n"],
|
||||
"prefill_tps": round(1000 * tm["prompt_n"] / tm["prompt_ms"], 1),
|
||||
"predicted_n": tm["predicted_n"],
|
||||
"decode_tps": round(tm["predicted_per_second"], 2),
|
||||
"draft_n": tm.get("draft_n", 0),
|
||||
"accepted": tm.get("draft_n_accepted", 0),
|
||||
"answer": content,
|
||||
"reasoning_len": len(msg.get("reasoning_content") or "")}
|
||||
if label.startswith("echo"):
|
||||
# How much of the answer really is the passage: a configuration that
|
||||
# "wins" by answering something else is not a win.
|
||||
want = {l.strip() for l in echo_passage().splitlines()}
|
||||
got = [l.strip() for l in content.splitlines() if l.strip()]
|
||||
row["copy_ratio"] = round(sum(1 for l in got if l in want) / len(got), 3) if got else 0.0
|
||||
return row
|
||||
|
||||
|
||||
def measure(base):
|
||||
run_shape(base, FILLER * (4000 // TOKENS_PER_REP) +
|
||||
"\n\nReply with a single short sentence.", 32, "warmup")
|
||||
return [
|
||||
run_shape(base, PROSE, 512, "prose 1k/512"),
|
||||
run_shape(base, ECHO, 400, "echo 1.5k/400", NO_THINK),
|
||||
run_shape(base, STRUCTURED, 400, "structured/400", NO_THINK),
|
||||
run_shape(base, IDENTITY, 192, "identity"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("env_file")
|
||||
ap.add_argument("serve_script")
|
||||
ap.add_argument("--base-url", default="http://127.0.0.1:7770")
|
||||
ap.add_argument("--log", default="/tmp/bench-spec-ab.log")
|
||||
ap.add_argument("--configs", default=None,
|
||||
help="name=SPEC_TYPE[@n_max][|ENV=VAL] joined by ';' "
|
||||
"(default: the four configs measured on 2026-09-16)")
|
||||
ap.add_argument("--json", default="/tmp/bench-spec-ab.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
configs = parse_configs(args.configs) if args.configs else DEFAULT_CONFIGS
|
||||
# Absolute paths: the launcher must not depend on the caller's cwd, and a
|
||||
# relative path plus a `cd` to its own directory double-counts the prefix.
|
||||
env_file = os.path.abspath(args.env_file)
|
||||
serve_script = os.path.abspath(args.serve_script)
|
||||
base, logfile = args.base_url, args.log
|
||||
results = {}
|
||||
log("spec A/B start: " + ", ".join(n for n, _ in configs) +
|
||||
" (unit " + UNIT + " stopped for the duration)", logfile)
|
||||
try:
|
||||
stop_unit()
|
||||
for name, over in configs:
|
||||
env_path = write_env(env_file, name, over)
|
||||
logf = "/tmp/spec-ab-" + name.replace("/", "_") + ".log"
|
||||
sh("FRANK_MEDIUM_ENV_FILE=" + env_path + " setsid bash " + serve_script +
|
||||
" > " + logf + " 2>&1 < /dev/null &")
|
||||
if not wait_health(base):
|
||||
log(name + ": FAILED to become healthy -- " + sh("tail -3 " + logf), logfile)
|
||||
results[name] = {"error": "not healthy", "tail": sh("tail -6 " + logf)}
|
||||
stop_unit()
|
||||
continue
|
||||
rows = measure(base)
|
||||
results[name] = {"vram_gb": vram(), "rows": rows,
|
||||
"spec_log": sh("grep -E '" + SPEC_GREP + "' " + logf + " | tail -4")}
|
||||
for r in rows:
|
||||
log(name + " | " + r["shape"] + " prefill " + str(r["prefill_tps"]) +
|
||||
" tok/s | decode " + str(r["decode_tps"]) + " tok/s | draft " +
|
||||
str(r["draft_n"]) + "/" + str(r["accepted"]) +
|
||||
(" (" + str(round(100.0 * r["accepted"] / r["draft_n"])) + " %)"
|
||||
if r["draft_n"] else "") +
|
||||
(" | copy " + str(r["copy_ratio"]) if "copy_ratio" in r else "") +
|
||||
(" | EMPTY ANSWER" if not r["answer"] else ""), logfile)
|
||||
log(name + " | vram " + str(results[name]["vram_gb"]), logfile)
|
||||
stop_unit()
|
||||
finally:
|
||||
restore_unit(base, logfile)
|
||||
|
||||
with open(args.json, "w") as fh:
|
||||
json.dump(results, fh, indent=2)
|
||||
for shape in ("prose 1k/512", "echo 1.5k/400", "structured/400", "identity"):
|
||||
texts = [r["answer"] for v in results.values() if "rows" in v
|
||||
for r in v["rows"] if r["shape"] == shape]
|
||||
if any(not t for t in texts):
|
||||
log("WARNING: '" + shape + "' produced an empty answer in some configuration -- "
|
||||
"its identity check proves nothing", logfile)
|
||||
elif len(set(texts)) > 1:
|
||||
log("WARNING: configurations disagree on greedy text for '" + shape +
|
||||
"' -- speculation is changing answers", logfile)
|
||||
log("done; results in " + args.json, logfile)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,318 +0,0 @@
|
||||
# Tunables for frank-medium (orcarouter/Qwen3.8-27B-Uncensored-GGUF Q8_0 +
|
||||
# z-lab/Qwen3.8-27B-DFlash2-GGUF Q4_K_M drafter), mainline llama.cpp on
|
||||
# Dankserver, 2x AMD Radeon AI PRO R9700 (gfx1201). Since 2026-09-16 this is
|
||||
# the SOLE LLM runtime on the box: the vllm-radiance replica pair and its
|
||||
# routing proxy are retired.
|
||||
#
|
||||
# Hardware: two R9700 (Navi 48, RDNA4, 32,624 MiB each), no XGMI, different
|
||||
# IOMMU groups -- every cross-card byte goes over PCIe. The Raphael iGPU
|
||||
# (gfx1036/Radeon 610M) enumerates as HIP device 2 and is hidden below so
|
||||
# nothing can drift onto shared system RAM.
|
||||
#
|
||||
# WHY LAYER SPLIT AND NOT TENSOR (operator call, 2026-09-16): tensor parallel
|
||||
# needs a cross-card allreduce per layer, and this host cannot dispatch one.
|
||||
# RCCL's kernels carry a hidden hostcall-buffer parameter, and ROCr refuses it
|
||||
# on a device without PCIe atomics -- the second card sits behind the B650
|
||||
# chipset bridges (AtomicOpsCap: Routing-, read-only), so every collective that
|
||||
# touches it dies with hipErrorIllegalState. Measured on this box 2026-09-15
|
||||
# (llama-bench, pp4096 / tg128, f16 KV, fa on):
|
||||
#
|
||||
# single R9700 1160.9 / 19.42
|
||||
# layer 2x 1991.2 / 18.76 (859.1 / 17.64 at depth 32768)
|
||||
# tensor 2x 271.8 / 26.26 (no RCCL: butterfly fallback, -7x prefill)
|
||||
#
|
||||
# Layer split distributes layers in order; only the activation at the boundary
|
||||
# crosses the wire, once per forward pass. That is the "pipeline parallelism"
|
||||
# this service wanted, and it needs no collective at all.
|
||||
#
|
||||
# Runtime: mainline llama.cpp master fb27a525 (2026-09-16), gfx1201, built by
|
||||
# scripts/build-llama-rdna4.sh against a private ROCm 7.14.0a20260624 toolchain
|
||||
# that is bundled next to the binary (RPATH $ORIGIN/../lib, no LD_LIBRARY_PATH).
|
||||
# The ref is pinned by TWO features: DFlash2 (ggml-org/llama.cpp#27342 -- the
|
||||
# drafter is detected from its GGUF) and qwen35 MTP. Do not float it.
|
||||
#
|
||||
# Model: orcarouter/Qwen3.8-27B-Uncensored-GGUF @ Q8_0, 27.0 GiB, arch qwen35
|
||||
# (verified from the GGUF header 2026-09-19: 262144 trained window, 24 heads /
|
||||
# 4 KV heads, nextn_predict_layers=1). The file ALSO carries the model's MTP
|
||||
# head (blk.64.nextn.*), so --spec-type draft-mtp is available without a second
|
||||
# copy in VRAM -- one SPEC_TYPE change, the drafter below stays on disk.
|
||||
#
|
||||
# Drafter: z-lab/Qwen3.8-27B-DFlash2-GGUF @ Q4_K_M, 1.06 GiB (operator call
|
||||
# 2026-09-19: the Q4 rung -- the drafter's job is throughput, not output
|
||||
# quality, and the target verifies every drafted token). 5 layers, hidden
|
||||
# 5120, a block-diffusion proposal (block_size 8) under a 2048-token sliding
|
||||
# window (dflash.attention.sliding_window=2048, verified from the header).
|
||||
# The window is what makes it cheap HERE: its KV is bounded at 2048 positions
|
||||
# (~40 MiB) instead of spanning the context (see SIZING).
|
||||
#
|
||||
# Architecture (GGUF arch qwen35): 64 layers as 16 x (3 x (Gated DeltaNet ->
|
||||
# FFN) + 1 x (Gated Attention -> FFN)) -- 48 linear-attention layers whose
|
||||
# per-sequence state is CONSTANT-SIZE, 16 full-attention layers at 4 KV heads x
|
||||
# 256 head_dim. A token therefore costs 2 x 4 x 256 x 2 B = 64 KiB of KV at f16
|
||||
# in the attention layers only, plus ~0.16 GiB per sequence of f32 DeltaNet
|
||||
# state at any depth.
|
||||
#
|
||||
# SIZING, the deployed shape (32:33 / ctx 327680 / 6144x512 / two slots /
|
||||
# DFlash2 Q4_K_M), measured 2026-09-19 after the model swap (rocm-smi):
|
||||
#
|
||||
# card 0 (x16) 26.9 GiB used, 5.7 GiB free
|
||||
# card 1 (x1) 27.8 GiB used, 4.8 GiB free
|
||||
#
|
||||
# The predecessor's measured table (rocm-smi, 2026-09-16, UD-Q8_K_XL target)
|
||||
# stays as the calibration history:
|
||||
#
|
||||
# CTX split batch slots drafter GPU 0 free GPU 1 free note (ubatch 1536)
|
||||
# 262144 1,1 2048 1 DFlash2 8.64 GiB 4.43 GiB day's start
|
||||
# 262144 39,26 6144 2 DFlash2 2.42 GiB 7.20 GiB
|
||||
# 262144 38,27 6144 2 DFlash2 3.85 GiB 5.77 GiB
|
||||
# 262144 37,28 6144 2 DFlash2 4.28 GiB 5.34 GiB
|
||||
# 262144 36,29 6144 2 DFlash2 4.71 GiB 4.91 GiB
|
||||
# 262144 35,30 6144 2 DFlash2 5.14 GiB 4.48 GiB
|
||||
# 262144 40,25 6144 2 DSpark 1.87 GiB 1.86 GiB (retired)
|
||||
#
|
||||
# ...and the same deployed shape re-measured at ubatch 512, after llama-bench
|
||||
# showed 512 is the throughput optimum and costs 3.71 GiB less in total (see the
|
||||
# batch block below):
|
||||
#
|
||||
# 262144 36,29 6144 512 2 DFlash2 6.01 GiB 7.32 GiB ubatch only
|
||||
# 262144 35,30 6144 512 2 DFlash2 6.44 GiB 6.90 GiB
|
||||
#
|
||||
# ONE -ts STEP IS ONE LAYER, AND LAYERS ARE NOT THE SAME SIZE. This is a hybrid
|
||||
# stack -- 16 x (3 Gated DeltaNet + 1 Gated Attention) blocks -- so moving a
|
||||
# DeltaNet layer shifts ~0.43 GiB while an attention layer shifts ~1.43 GiB
|
||||
# (0.45 of weights plus ~1.0 GiB of its own f16 KV at 262144). Both step sizes
|
||||
# were measured, which is why the balance is found by measurement rather than by
|
||||
# interpolating between splits.
|
||||
#
|
||||
# WHY THE SPLIT IS 32:33 (operator call, 2026-09-19). The operator asked for
|
||||
# the tensors BALANCED across the two cards, which for 65 layers is 32:33 --
|
||||
# the most even split that exists. The predecessor's 35:30 skew existed to lean
|
||||
# work onto the x16 card (ROCm0), because ROCm1's Gen4 x1 chipset path made it
|
||||
# the 98 % utilization bottleneck; the measured trade of the balanced point is
|
||||
# that the x1 card runs hotter while the x16 card idles a little more, and the
|
||||
# operator accepted that for symmetry. Do not re-skew without asking.
|
||||
#
|
||||
# THE TWO CARDS' PATHS ARE NOT SYMMETRIC (measured, 2026-09-17): ROCm0
|
||||
# (03:00.0) hangs off CPU root port 00:01.1 at Gen4 x16 end to end (~31.5 GB/s);
|
||||
# ROCm1 (0b:00.0) rides the B650 chipset through bridges that each negotiate
|
||||
# Gen4 x1 (~1.97 GB/s). The layer-split boundary itself is not the cost
|
||||
# (~5.2 MB of activation per 512-token ubatch rides the wire); the x1 card is
|
||||
# COMPUTE-bound, not PCIe-bound.
|
||||
#
|
||||
# A FAULT TO WATCH. A 39:26 split, at 1x batch / 1 slot, died on its eighth
|
||||
# request: the amdgpu driver logged SDMA0 UTCL2 page faults and the engine
|
||||
# surfaced `ROCm error: an illegal memory access was encountered` -> ggml_abort,
|
||||
# i.e. a fault in the cross-card boundary copy, this layer split's hot path. It
|
||||
# has not reproduced since, but the signature is worth recognising -- it is NOT
|
||||
# an OOM, and no amount of headroom prevents it.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (HIP reads
|
||||
# those bare vars; 2026-08-13 incident). llama.cpp takes --host/--port/--temp
|
||||
# as flags only.
|
||||
|
||||
# llama-server: mainline fb27a525 built for gfx1201 by scripts/build-llama-rdna4.sh.
|
||||
LLAMA_BIN=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server
|
||||
|
||||
# Payload, fetched with scripts/download-frank-medium-gguf.sh.
|
||||
MODEL_DIR=/home/dank/models/Qwen3.8-27B-Uncensored-GGUF
|
||||
MODEL_FILE=Qwen3.8-27B-Uncensored-Q8_0.gguf
|
||||
# The f16 vision projector, same repo as the target. llama-server loads it via
|
||||
# the explicit --mmproj flag (no autoload in this build); the serve script
|
||||
# refuses to start without it when vision is enabled, so a missing file is a
|
||||
# deploy error, not a text-only server that pretends to be multimodal.
|
||||
MM_PROJ_FILE=mmproj-Qwen3.8-27B-Uncensored-f16.gguf
|
||||
DRAFT_MODEL_DIR=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF
|
||||
DRAFT_MODEL_FILE=Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
||||
|
||||
# Fleet identity. 7770 is the fleet's dankserver endpoint; the alias is the id
|
||||
# at /v1/models and what every client asks for.
|
||||
ALIAS=frank-medium
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
# Device placement. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2)
|
||||
# so nothing can drift onto shared system RAM; DEVICES is what llama.cpp splits
|
||||
# across, and neither of the two cards has an XGMI link to the other.
|
||||
HIP_VISIBLE_DEVICES=0,1
|
||||
DEVICES=ROCm0,ROCm1
|
||||
SPLIT_MODE=layer
|
||||
|
||||
# 32:33 -- balanced (operator call 2026-09-19): 65 layers split as evenly as
|
||||
# they can be, ROCm1 (the last-layers card, which also carries the drafter and
|
||||
# the output layers) holding the extra one. See the WHY THE SPLIT IS 32:33
|
||||
# block above for the trade against the predecessor's 35:30 x16-lean.
|
||||
TENSOR_SPLIT=32,33
|
||||
|
||||
NGL=999
|
||||
# Auto-fit has no business resizing a context we set explicitly.
|
||||
FIT=off
|
||||
FLASH_ATTN=on
|
||||
|
||||
# 327680 positions over TWO slots, so each conversation gets 163840 --
|
||||
# --parallel DIVIDES --ctx-size, so more slots would shrink every
|
||||
# conversation rather than grow the cache. 163840 is inside the checkpoint's
|
||||
# trained 262144 window, so the whole pool is trained territory (no rope
|
||||
# extrapolation, unlike the predecessor's 393216).
|
||||
CTX_SIZE=327680
|
||||
PARALLEL=2
|
||||
|
||||
# MEASURED 2026-09-16 with scripts/bench-batch-ubatch.sh on this box: llama-bench,
|
||||
# a 64k prefill plus a 128-token decode with 64k of KV already resident, 3 reps,
|
||||
# one invocation per cell, the deployed split and f16 KV. --ubatch-size is the
|
||||
# physical batch the graph is built at -- and the only one of the two knobs that
|
||||
# costs VRAM:
|
||||
#
|
||||
# batch ubatch prefill tok/s GPU 0 / GPU 1 free
|
||||
# 6144 1536 1164.96 +- 2.30 10.28 / 11.71 GiB
|
||||
# 6144 768 1135.03 +- 1.09 11.29 / 13.28 GiB
|
||||
# 6144 512 1192.93 +- 0.91 11.63 / 13.81 GiB <- deployed
|
||||
# 6144 384 1068.04 +- 0.79 11.80 / 14.07 GiB
|
||||
# 6144 256 1045.98 +- 1.69 11.97 / 14.33 GiB
|
||||
# 4096 1536 1161.90 +- 0.96 10.28 / 11.71 GiB
|
||||
# 2048 1536 1170.23 +- 0.86 10.28 / 11.71 GiB
|
||||
# 2048 512 1193.88 +- 1.01 11.63 / 13.81 GiB
|
||||
#
|
||||
# THE PLATEAU STARTS AT 512. 512 -> 1536 buys ~2 % of prefill -- inside the
|
||||
# run-to-run spread, and the 512 cells were the faster pair in BOTH batch
|
||||
# columns -- while costing 1.35 GiB on ROCm0 and 2.10 GiB on ROCm1, so the
|
||||
# deployment takes the cheap end. BELOW 512 the SMs are no longer saturated:
|
||||
# 384 and 256 fall ~11 % and ~12 %. The logical batch is free either way --
|
||||
# 2048, 4096 and 6144 land within 2 % of each other at a fixed ubatch and use
|
||||
# the SAME VRAM, because the compute buffers are sized by ubatch alone.
|
||||
#
|
||||
# The prefill figure is a property of the SHAPE, not of this knob: the same
|
||||
# sweep with no depth in front of it (depth 0) runs 1543 tok/s at ubatch 256 and
|
||||
# 1723 at 1536 -- the plateau is in the same place, and the ~32 % between the two
|
||||
# tables is what attending over 64k of resident KV costs. Decode is 15.88 tok/s
|
||||
# at depth 65536 on EVERY ubatch (17.80 at depth 0), so this knob cannot touch
|
||||
# generation at all.
|
||||
#
|
||||
# 8192/2048 stays untested under this drafter (the "2020 MiB pp buffer fails on
|
||||
# ROCm1" note was measured under DSpark, when it had 0.08 GiB free), and there is
|
||||
# no longer a reason to want it: batch buys nothing measurable here.
|
||||
BATCH_SIZE=6144
|
||||
UBATCH_SIZE=512
|
||||
|
||||
# Threads cover tokenization, sampling and HTTP only: every layer is on a GPU.
|
||||
NUM_THREADS=8
|
||||
THREAD_BATCH=8
|
||||
|
||||
# f16 K/V: an agentic checkpoint is the wrong place to spend KV precision.
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
# Context checkpoints: 0 -- no per-slot KV snapshots at all. The build's
|
||||
# default is 32 (this binary's --help), so the 0 below is a deliberate
|
||||
# deviation, not an inherited default: a checkpoint is a VRAM->host-RAM copy
|
||||
# of part of a slot's KV, taken so a partially-matching prompt can be restored
|
||||
# instead of re-prefilled. This endpoint trades that away -- its reuse comes
|
||||
# from the slot prompt and from the RAM-side prompt cache below. Stated
|
||||
# explicitly so the tunable stays visible rather than merely absent.
|
||||
CTX_CHECKPOINTS=0
|
||||
# PROMPT CACHE, HOST RAM -- this is the one cache tunable that is NOT VRAM.
|
||||
# --cache-ram caps the total bytes of saved prompt states
|
||||
# (server_prompt_cache, tools/server/server-task.cpp): update() evicts
|
||||
# oldest-first past the cap, and alloc() REFUSES outright any single state
|
||||
# bigger than the whole cap. A state is the serialized KV of a whole sequence
|
||||
# plus its DeltaNet state plus the draft context's own KV, so at this shape it
|
||||
# costs ~68 KiB per token --
|
||||
# 16 full-attention layers x 4 kv heads x 256 dim x 2 B x 2 (K,V) = 64 KiB
|
||||
# plus the draft context's window-bounded KV (a ~40 MiB constant for DFlash2)
|
||||
# -- which makes the cap a TOKEN budget in disguise: 16384 MiB holds ~240k
|
||||
# tokens, i.e. a FULL 163840-token slot (~10.6 GiB) with ~5 GiB of the budget
|
||||
# spare for a second entry.
|
||||
#
|
||||
# The cost is host RAM, allocated on demand and never pinned (plain heap
|
||||
# vectors), so no VRAM number above moves. dankserver has 62 GiB beside the
|
||||
# media-stack co-tenants, so the cache is bounded rather than unlimited (-1):
|
||||
# the seam test refuses a budget over half the host's RAM.
|
||||
CACHE_RAM=16384
|
||||
|
||||
# Sampling defaults for requests that send none -- the values the vLLM
|
||||
# predecessor served on this endpoint (`--override-generation-config`), so a
|
||||
# client sees no change across the runtime swap. The model card's own
|
||||
# recommendation is temp 1.0 / top_p 0.95; the harness sets its own per model in
|
||||
# models.yml, so this is the floor, not the contract.
|
||||
TEMPERATURE=0.7
|
||||
TOP_P=0.95
|
||||
TOP_K=20
|
||||
MIN_P=0.0
|
||||
PRESENCE_PENALTY=0.0
|
||||
|
||||
# Reasoning on, projected into reasoning_content (deepseek format) with no
|
||||
# budget: the model decides how long to think.
|
||||
REASONING=on
|
||||
REASONING_FORMAT=deepseek
|
||||
REASONING_BUDGET=-1
|
||||
|
||||
# Chat template: empty = the one embedded in the GGUF (Qwen3.8's own).
|
||||
CHAT_TEMPLATE_FILE=
|
||||
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}'
|
||||
|
||||
# Speculative decoding: the DFlash2 sidecar (operator call 2026-09-19). The
|
||||
# target file also carries its own MTP head (nextn_predict_layers=1), so
|
||||
# draft-mtp is one SPEC_TYPE change away -- the drafter file stays on disk.
|
||||
# n_max 7 is the measured DFlash2 winner (see the table below); the drafter
|
||||
# rides ROCm1, the card that holds the last layers.
|
||||
SPEC_TYPE=draft-dflash
|
||||
SPEC_DRAFT_N_MAX=7
|
||||
SPEC_DRAFT_NGL=999
|
||||
SPEC_DRAFT_DEVICE=ROCm1
|
||||
SPEC_DRAFT_TYPE_K=f16
|
||||
SPEC_DRAFT_TYPE_V=f16
|
||||
|
||||
# DFlash2 vs the model's own MTP head -- MEASURED 2026-09-16 on the predecessor
|
||||
# payload (temperature 0, 1k prompt / 512 out; the autoregressive baseline and
|
||||
# both MTP depths were run in one session, same binary, same prompts):
|
||||
#
|
||||
# none 17.7 tok/s card1 26.07 GB
|
||||
# draft-mtp n=3 32.4 tok/s 58 % accepted card1 29.35 GB
|
||||
# draft-mtp n=7 26.7 tok/s 33 % accepted card1 29.65 GB
|
||||
# draft-dflash 7 43.5 tok/s 41 % accepted card1 29.42 GB
|
||||
#
|
||||
# All four produced IDENTICAL greedy text, so verification is lossless
|
||||
# whichever proposer runs. DFlash2 is the faster proposer (2.46x over
|
||||
# autoregressive, 34 % over the best MTP). The MTP head adds ~3.3 GB to the
|
||||
# card that holds it, against 1.06 GB for the Q4_K_M drafter's file -- at the
|
||||
# 327680 pool the drafter's window-bounded KV (~40 MiB) leaves headroom on
|
||||
# both cards, so the speed wins.
|
||||
#
|
||||
# THE TWO CANNOT BE CHAINED. `--spec-type draft-mtp,draft-dflash` dies at load:
|
||||
# context type MTP requested but model doesn't contain MTP layers
|
||||
# failed to create MTP context
|
||||
# A chain has ONE draft context, and MTP builds against whatever model that
|
||||
# context holds -- here the DFlash2 file, which has no MTP layers. MTP's head is
|
||||
# only loaded when MTP IS the draft model ("creating MTP draft context against
|
||||
# the target model ..."), so a chain of the two is a structural dead end, not a
|
||||
# tuning question. See serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md.
|
||||
|
||||
# ngram-mod: the draftless n-gram matcher (CPU hash table in host RAM, no VRAM,
|
||||
# no model file, no draft context). It joins the chain unconditionally --
|
||||
# unlike MTP, which needs a draft context and therefore cannot share one with
|
||||
# DFlash2. NOT CURRENTLY ACTIVE: SPEC_TYPE is draft-dflash alone, and the serve
|
||||
# script only passes --spec-ngram-mod-* when the type contains "ngram". The
|
||||
# window below is retained because the DFlash2 + ngram chain was measured free
|
||||
# on prose and 2.6x on prompt-echo -- one SPEC_TYPE change brings it back.
|
||||
#
|
||||
# WINDOW MEASURED 2026-09-16 (chain at each window, decode tok/s):
|
||||
#
|
||||
# n_min/n_max prose 1k/512 prompt-echo 1.5k/400
|
||||
# 48/64 43.9 200.6
|
||||
# 16/32 36.0 161.0
|
||||
# 8/16 36.2 113.8
|
||||
#
|
||||
# The long window is the right one, and not only because it wins on echo: a
|
||||
# SHORT window makes the matcher propose on short fragments of ordinary prose,
|
||||
# where the proposals are rejected and the verification still costs -- which is
|
||||
# the "ngram hurts" result the fleet measured on other payloads twice before.
|
||||
# With 48/64 the chain is byte-identical to DFlash2 alone on prose (same 119
|
||||
# drafts / 49 accepted) and on the identity prompt (70/53).
|
||||
#
|
||||
# Do not shorten these without re-running scripts/bench-spec-ab.py: the
|
||||
# no-penalty property is what makes the chain safe as a default.
|
||||
SPEC_NGRAM_N_MATCH=24
|
||||
SPEC_NGRAM_N_MIN=48
|
||||
SPEC_NGRAM_N_MAX=64
|
||||
|
||||
# Vision enabled: llama-server loads mmproj-Qwen3.8-27B-Uncensored-f16.gguf
|
||||
# (931 MiB, lands on ROCm0 -- the fit charges it there) via the explicit
|
||||
# --mmproj flag. This keeps frank-medium multimodal across the model swap.
|
||||
NO_MMPROJ=0
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=llama.cpp Qwen3.8-27B Uncensored UD-Q8_K_XL + DFlash2 drafter (dankserver, frank-medium, 2x Radeon AI PRO R9700)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
# The engine pins staging buffers on the first card; keep the memlock budget
|
||||
# unbounded so an RLIMIT that is smaller than the split buffers cannot fail the
|
||||
# load. Parity with the other LLM units on this fleet.
|
||||
LimitMEMLOCK=infinity
|
||||
Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF @ UD-Q8_K_XL as
|
||||
# frank-medium on Dankserver's two Radeon AI PRO R9700 (gfx1201), with the
|
||||
# z-lab DFlash2 block-diffusion drafter, using mainline llama.cpp.
|
||||
#
|
||||
# Runtime: llama.cpp master fb27a525 (2026-09-16), a gfx1201 HIP build that
|
||||
# bundles its own ROCm 7.14 runtime (RPATH $ORIGIN/../lib), produced by
|
||||
# scripts/build-llama-rdna4.sh. Two features pin that ref: DFlash2
|
||||
# (ggml-org/llama.cpp#27342) and qwen35 MTP. The charlie12345/ROCmFPX fork
|
||||
# this deployment replaced has neither (its tip is c49ebdb, 2026-08-22).
|
||||
#
|
||||
# Parallelism: --split-mode layer across both cards. Tensor split would need a
|
||||
# cross-card allreduce and this host cannot dispatch one -- RCCL's kernels
|
||||
# require a hostcall buffer that ROCr refuses on a card without PCIe atomics,
|
||||
# and the second R9700 sits behind the chipset (AtomicOpsCap: Routing-). Layer
|
||||
# split crosses the wire once per forward pass instead of once per layer.
|
||||
#
|
||||
# The vLLM replica pair and its routing proxy were retired with this cutover:
|
||||
# one process, one model, both cards.
|
||||
#
|
||||
# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE overrides it.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) ------------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF}"
|
||||
: "${MODEL_FILE:=Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf}"
|
||||
: "${DRAFT_MODEL_DIR:=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF}"
|
||||
: "${DRAFT_MODEL_FILE:=Qwen3.8-27B-DFlash2-Q8_0.gguf}"
|
||||
: "${ALIAS:=frank-medium}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
|
||||
# Devices. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2) so nothing
|
||||
# can drift onto shared system RAM; DEVICES is what llama.cpp splits across.
|
||||
: "${HIP_VISIBLE_DEVICES:=0,1}"
|
||||
: "${DEVICES:=ROCm0,ROCm1}"
|
||||
: "${SPLIT_MODE:=layer}"
|
||||
: "${TENSOR_SPLIT:=1,1}"
|
||||
: "${NGL:=999}"
|
||||
: "${FIT:=off}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
|
||||
: "${CTX_SIZE:=131072}"
|
||||
: "${PARALLEL:=1}"
|
||||
: "${BATCH_SIZE:=2048}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=8}"
|
||||
: "${THREAD_BATCH:=8}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${CTX_CHECKPOINTS:=8}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
|
||||
# Sampling defaults for silent clients: the values the vLLM predecessor served.
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_P:=0.95}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${PRESENCE_PENALTY:=0.0}"
|
||||
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${REASONING_BUDGET:=-1}"
|
||||
: "${CHAT_TEMPLATE_FILE:=}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}"
|
||||
|
||||
# Speculative decoding: draft-dflash or draft-dspark (both are MODEL drafters --
|
||||
# a file, not a flag), or draft-mtp (the target GGUF's own MTP head, no draft
|
||||
# file). The drafter's own tuning knobs (n-min, p-min, p-split, poll) are left
|
||||
# at the build's defaults: they were only ever passed for dspark, and dspark is
|
||||
# not the deployed drafter.
|
||||
: "${SPEC_TYPE:=draft-dflash}"
|
||||
: "${SPEC_DRAFT_N_MAX:=7}"
|
||||
: "${SPEC_DRAFT_NGL:=999}"
|
||||
: "${SPEC_DRAFT_DEVICE:=}"
|
||||
: "${SPEC_DRAFT_TYPE_K:=f16}"
|
||||
: "${SPEC_DRAFT_TYPE_V:=f16}"
|
||||
: "${SPEC_NGRAM_N_MATCH:=24}"
|
||||
: "${SPEC_NGRAM_N_MIN:=48}"
|
||||
: "${SPEC_NGRAM_N_MAX:=64}"
|
||||
|
||||
NO_MMPROJ="${NO_MMPROJ:-0}"
|
||||
: "${MM_PROJ_FILE:=mmproj-BF16.gguf}"
|
||||
|
||||
export HIP_VISIBLE_DEVICES
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE"
|
||||
|
||||
if [[ ! -x "$LLAMA_BIN" ]]; then
|
||||
echo "missing executable runtime: $LLAMA_BIN" >&2
|
||||
echo "build it with: bash $SCRIPT_DIR/build-llama-rdna4.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$MODEL" ]]; then
|
||||
echo "missing or unreadable target model: $MODEL" >&2
|
||||
echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NO_MMPROJ" != "1" ]]; then
|
||||
MM_PROJ="$MODEL_DIR/$MM_PROJ_FILE"
|
||||
if [[ ! -r "$MM_PROJ" ]]; then
|
||||
echo "vision is enabled but the projector is missing: $MM_PROJ" >&2
|
||||
echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- speculative decoding -------------------------------------------------
|
||||
SPEC_ARGS=()
|
||||
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
|
||||
SPEC_ARGS=(--spec-type "$SPEC_TYPE" --spec-draft-n-max "$SPEC_DRAFT_N_MAX")
|
||||
if [[ "$SPEC_TYPE" == *dflash* || "$SPEC_TYPE" == *dspark* ]]; then
|
||||
if [[ ! -r "$DRAFT_MODEL" ]]; then
|
||||
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SPEC_ARGS+=(
|
||||
--spec-draft-model "$DRAFT_MODEL"
|
||||
--spec-draft-ngl "$SPEC_DRAFT_NGL"
|
||||
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
|
||||
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
|
||||
)
|
||||
fi
|
||||
# ngram-mod is draftless: it needs no draft context and its window is its
|
||||
# own (--spec-ngram-mod-*), so it can ride in the chain beside a model
|
||||
# drafter. It also costs no VRAM -- a CPU hash table.
|
||||
if [[ "$SPEC_TYPE" == *ngram* ]]; then
|
||||
SPEC_ARGS+=(
|
||||
--spec-ngram-mod-n-match "$SPEC_NGRAM_N_MATCH"
|
||||
--spec-ngram-mod-n-min "$SPEC_NGRAM_N_MIN"
|
||||
--spec-ngram-mod-n-max "$SPEC_NGRAM_N_MAX"
|
||||
)
|
||||
fi
|
||||
if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then
|
||||
SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE")
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- template + placement -------------------------------------------------
|
||||
TEMPLATE_ARGS=()
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE")
|
||||
fi
|
||||
TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS")
|
||||
|
||||
PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE" --tensor-split "$TENSOR_SPLIT")
|
||||
|
||||
MMPROJ_ARGS=()
|
||||
if [[ "$NO_MMPROJ" == "1" ]]; then
|
||||
MMPROJ_ARGS=(--no-mmproj)
|
||||
else
|
||||
# No autoload in this build: a server without the flag starts text-only
|
||||
# and answers image requests with a 500, so name the projector.
|
||||
MMPROJ_ARGS=(--mmproj "$MM_PROJ")
|
||||
fi
|
||||
exec "$LLAMA_BIN" \
|
||||
--model "$MODEL" \
|
||||
--alias "$ALIAS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--jinja \
|
||||
--gpu-layers "$NGL" \
|
||||
--fit "$FIT" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--ctx-checkpoints "$CTX_CHECKPOINTS" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--cache-prompt \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-p "$TOP_P" \
|
||||
--top-k "$TOP_K" \
|
||||
--min-p "$MIN_P" \
|
||||
--presence-penalty "$PRESENCE_PENALTY" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget "$REASONING_BUDGET" \
|
||||
"${TEMPLATE_ARGS[@]}" \
|
||||
"${PLACEMENT_ARGS[@]}" \
|
||||
"${MMPROJ_ARGS[@]}" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
@@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Decode throughput benchmark for frank-dragoon.
|
||||
Starts the server with a given --threads-batch value, sends an 8K+ token prompt,
|
||||
generates 256 tokens, measures generation tok/s.
|
||||
|
||||
Each --threads-batch value requires a cold server load (~3 min).
|
||||
"""
|
||||
import json, time, urllib.request, urllib.error, subprocess, os, sys
|
||||
|
||||
MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf"
|
||||
SERVE = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server"
|
||||
PORT = 7770
|
||||
N_GEN = 256
|
||||
PROMPT_FILE = "/tmp/bench-prompts/prompt_code.txt"
|
||||
ENV_FILE = os.path.join(os.path.dirname(__file__), "frank-dragoon.env")
|
||||
SERVER_DIR = os.path.dirname(SERVE)
|
||||
ROCM_PATH = "/opt/rocm"
|
||||
|
||||
def make_env():
|
||||
"""Build the same env as serve-frank-dragoon.sh."""
|
||||
env = {**os.environ, "HSA_OVERRIDE_GFX_VERSION": "11.5.1", "HIP_VISIBLE_DEVICES": "0"}
|
||||
|
||||
# Source env file for tunables
|
||||
if os.path.exists(ENV_FILE):
|
||||
with open(ENV_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v
|
||||
|
||||
# Build LD_LIBRARY_PATH
|
||||
lib_path = f"{SERVER_DIR}:{ROCM_PATH}/lib:{ROCM_PATH}/lib/rocm_sysdeps/lib:{ROCM_PATH}/lib/llvm/lib"
|
||||
if os.environ.get("LD_LIBRARY_PATH"):
|
||||
lib_path += f":{os.environ['LD_LIBRARY_PATH']}"
|
||||
env["LD_LIBRARY_PATH"] = lib_path
|
||||
env["ROCM_PATH"] = ROCM_PATH
|
||||
|
||||
return env
|
||||
|
||||
ENV = make_env()
|
||||
|
||||
def kill_all():
|
||||
subprocess.run(["pkill", "-9", "llama-server"], capture_output=True)
|
||||
time.sleep(3)
|
||||
|
||||
def wait_ready():
|
||||
for i in range(600):
|
||||
try:
|
||||
r = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2)
|
||||
if json.loads(r.read()).get("status") == "ok":
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
def send_request(prompt, max_tokens=N_GEN):
|
||||
"""Send chat completion, measure generation throughput."""
|
||||
body = json.dumps({
|
||||
"model": "frank-dragoon",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.8
|
||||
}).encode()
|
||||
|
||||
t0 = time.time()
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{PORT}/v1/chat/completions",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
r = urllib.request.urlopen(req, timeout=1200)
|
||||
resp = json.loads(r.read())
|
||||
wall = time.time() - t0
|
||||
|
||||
usage = resp.get("usage", {})
|
||||
pt = usage.get("prompt_tokens", 0)
|
||||
gt = usage.get("completion_tokens", 0)
|
||||
gtps = gt / wall if wall > 0 else 0
|
||||
return pt, gt, wall, gtps
|
||||
|
||||
def load_and_extend_prompt():
|
||||
"""Load prompt and extend to ~8K tokens if needed."""
|
||||
with open(PROMPT_FILE) as f:
|
||||
text = f.read()
|
||||
|
||||
# Count approximate tokens (rough: ~4 chars per token for code)
|
||||
approx_tokens = len(text) / 4.0
|
||||
needed = 8200
|
||||
|
||||
if approx_tokens < needed:
|
||||
repeats = needed // max(int(approx_tokens), 1) + 1
|
||||
text = (text + "\n") * repeats
|
||||
words = text.split()[:needed]
|
||||
text = " ".join(words)
|
||||
|
||||
return text
|
||||
|
||||
def main():
|
||||
tb_values = [int(x) for x in sys.argv[1:]] if len(sys.argv) > 1 else [16, 32, 64, 128]
|
||||
results = []
|
||||
|
||||
prompt = load_and_extend_prompt()
|
||||
n_words = len(prompt.split())
|
||||
print(f"Prompt: {n_words} words (~{n_words} tokens), {len(prompt)} chars\n", flush=True)
|
||||
|
||||
for tb in tb_values:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"threads-batch = {tb}")
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
kill_all()
|
||||
print(" Starting server...", flush=True)
|
||||
|
||||
lib_path = ENV.get("LD_LIBRARY_PATH", "")
|
||||
cmd = [
|
||||
"/usr/bin/env",
|
||||
"HSA_OVERRIDE_GFX_VERSION=11.5.1",
|
||||
"HIP_VISIBLE_DEVICES=0",
|
||||
f"LD_LIBRARY_PATH={lib_path}",
|
||||
f"ROCM_PATH={ROCM_PATH}",
|
||||
SERVE, "--model", MODEL, "--port", str(PORT), "--jinja",
|
||||
"--device", "ROCm0", "--gpu-layers", "999", "--fit", "off",
|
||||
"--threads", "16", "--threads-batch", str(tb),
|
||||
"--parallel", "2", "--ctx-size", "131072",
|
||||
"--batch-size", "4096", "--ubatch-size", "4096",
|
||||
"--flash-attn", "on",
|
||||
"--cache-type-k", "f16", "--cache-type-v", "f16",
|
||||
"--cache-prompt", "--cache-idle-slots", "--metrics"
|
||||
]
|
||||
|
||||
srv = subprocess.Popen(cmd, env=ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
ready = wait_ready()
|
||||
if not ready:
|
||||
print(" FAILED: server did not become ready", flush=True)
|
||||
srv.terminate()
|
||||
srv.wait()
|
||||
for line in (srv.stdout.readlines() or [])[-20:]:
|
||||
print(f" LOG: {line.rstrip()}", flush=True)
|
||||
continue
|
||||
|
||||
print(" Server ready.", flush=True)
|
||||
|
||||
# Warmup (small request)
|
||||
try:
|
||||
send_request("Hi", 16)
|
||||
except Exception as e:
|
||||
print(f" Warmup failed: {e}", flush=True)
|
||||
srv.terminate()
|
||||
srv.wait()
|
||||
continue
|
||||
|
||||
# Benchmark
|
||||
print(f" Generating {N_GEN} tokens...", flush=True)
|
||||
try:
|
||||
pt, gt, wall, gtps = send_request(prompt, N_GEN)
|
||||
print(f" {gt}/{N_GEN} gen tokens in {wall:.1f}s = {gtps:.1f} tok/s")
|
||||
print(f" Prompt tokens: {pt}")
|
||||
results.append({"tb": tb, "wall": wall, "gtps": gtps, "gt": gt, "pt": pt})
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}", flush=True)
|
||||
|
||||
srv.terminate()
|
||||
srv.wait()
|
||||
time.sleep(2)
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
for r in results:
|
||||
print(f" tb={r['tb']:>3}: {r['gt']} gen tokens, {r['wall']:.1f}s, {r['gtps']:.1f} tok/s")
|
||||
|
||||
if results:
|
||||
best = max(results, key=lambda r: r['gtps'])
|
||||
print(f"\nBest: tb={best['tb']} at {best['gtps']:.1f} tok/s")
|
||||
|
||||
with open("/tmp/bench_decode_results.txt", "w") as f:
|
||||
f.write("tb,gen_tokens,wall_s,gen_tps,prompt_tokens\n")
|
||||
for r in results:
|
||||
f.write(f"{r['tb']},{r['gt']},{r['wall']:.1f},{r['gtps']:.1f},{r['pt']}\n")
|
||||
|
||||
print(f"\nResults: /tmp/bench_decode_results.txt")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark n-gram speculative decoding vs no-spec for frank-dragoon.
|
||||
|
||||
Tests prompt-processing throughput with 16K token prompts (manageable request size).
|
||||
Code-heavy prompt (high n-gram redundancy) vs prose prompt (low redundancy).
|
||||
|
||||
Usage:
|
||||
# Generate prompts: python3 scripts/bench-ngram-mod.py generate /tmp/prompts/
|
||||
# Run: python3 scripts/bench-ngram-mod.py /tmp/prompts/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf"
|
||||
CHAT_TEMPLATE = "/home/dank/serverAppConfigs/scripts/chat-template.jinja"
|
||||
BUILD_DIR = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin"
|
||||
LLAMA_BIN = os.path.join(BUILD_DIR, "llama-server")
|
||||
|
||||
# 16K tokens ~= 64KB text
|
||||
TARGET_PROMPT_TOKENS = 16000
|
||||
TOKEN_CHAR_RATIO = 4
|
||||
TARGET_PROMPT_BYTES = TARGET_PROMPT_TOKENS * TOKEN_CHAR_RATIO
|
||||
|
||||
CONTEXT = 65536
|
||||
BATCH = 4096
|
||||
UBATCH = 4096
|
||||
THREADS = 16
|
||||
THREAD_BATCH = 32
|
||||
NGRAM_N_MAX = 24
|
||||
NGRAM_N_MIN = 4
|
||||
TEST_TOKENS = 128
|
||||
SERVER_WAIT = 300
|
||||
|
||||
|
||||
def start_server(port, spec_type):
|
||||
cmd = [
|
||||
LLAMA_BIN,
|
||||
"--model", MODEL,
|
||||
"--chat-template-file", CHAT_TEMPLATE,
|
||||
"--jinja",
|
||||
"--device", "ROCm0",
|
||||
"--gpu-layers", "999",
|
||||
"--parallel", "1",
|
||||
"--ctx-size", str(CONTEXT),
|
||||
"--batch-size", str(BATCH),
|
||||
"--ubatch-size", str(UBATCH),
|
||||
"--fit", "off",
|
||||
"--flash-attn", "on",
|
||||
"--cache-type-k", "f16",
|
||||
"--cache-type-v", "f16",
|
||||
"--threads", str(THREADS),
|
||||
"--threads-batch", str(THREAD_BATCH),
|
||||
"--cache-prompt",
|
||||
"--cache-idle-slots",
|
||||
"--metrics",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(port),
|
||||
"--temp", "0",
|
||||
]
|
||||
if spec_type == "ngram-mod":
|
||||
cmd.extend([
|
||||
"--spec-type", "ngram-mod",
|
||||
"--spec-ngram-mod-n-max", str(NGRAM_N_MAX),
|
||||
"--spec-ngram-mod-n-min", str(NGRAM_N_MIN),
|
||||
])
|
||||
else:
|
||||
cmd.extend(["--spec-type", "none"])
|
||||
|
||||
env = os.environ.copy()
|
||||
env["ROCM_PATH"] = "/opt/rocm"
|
||||
env["LD_LIBRARY_PATH"] = f"{BUILD_DIR}:/opt/rocm/lib:/opt/rocm/lib/rocm_sysdeps/lib:/opt/rocm/lib/llvm/lib"
|
||||
env["HSA_OVERRIDE_GFX_VERSION"] = "11.5.1"
|
||||
env["HIP_VISIBLE_DEVICES"] = "0"
|
||||
|
||||
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
print(f" Waiting for server on port {port} (spec={spec_type})...")
|
||||
for i in range(SERVER_WAIT):
|
||||
try:
|
||||
req = urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
|
||||
resp = json.loads(req.read().decode())
|
||||
if resp.get("status") == "ok":
|
||||
print(f" Server ready after {i+1}s")
|
||||
return proc
|
||||
except:
|
||||
time.sleep(1)
|
||||
|
||||
proc.terminate()
|
||||
out, err = proc.communicate()
|
||||
print(f"ERROR: Server failed to start in {SERVER_WAIT}s")
|
||||
print(err.decode()[-2000:])
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_test(prompt_text, spec_type, port):
|
||||
"""Send a request and extract prompt processing metrics."""
|
||||
data = json.dumps({
|
||||
"model": "frank-dragoon",
|
||||
"messages": [{"role": "user", "content": prompt_text}],
|
||||
"max_tokens": TEST_TOKENS,
|
||||
"stream": False,
|
||||
"temperature": 0,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/v1/chat/completions",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
resp = urllib.request.urlopen(req, timeout=600)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
result = json.loads(resp.read().decode())
|
||||
timings = result.get("timings", {})
|
||||
usage = result.get("usage", {})
|
||||
|
||||
return {
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"prompt_ms": timings.get("prompt_ms", 0),
|
||||
"prompt_per_second": timings.get("prompt_per_second", 0),
|
||||
"prompt_per_token_ms": timings.get("prompt_per_token_ms", 0),
|
||||
"predicted_per_second": timings.get("predicted_per_second", 0),
|
||||
"total_time_s": elapsed,
|
||||
"cache_n": timings.get("cache_n", 0),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <prompt_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
prompt_dir = Path(sys.argv[1])
|
||||
prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
code_prompt_file = prompt_dir / "prompt_code.txt"
|
||||
prose_prompt_file = prompt_dir / "prompt_prose.txt"
|
||||
|
||||
# Generate prompts if they don't exist
|
||||
if not code_prompt_file.exists() or not prose_prompt_file.exists():
|
||||
print("Generating prompts...")
|
||||
# Generate deterministic prompts
|
||||
# Code prompt: structured algorithms + data generation + config schemas
|
||||
code_lines = []
|
||||
for i in range(50):
|
||||
code_lines.append(f"# Algorithm {i}\n")
|
||||
code_lines.append(f"def algo_{i}(x, y):\n")
|
||||
code_lines.append(f" \"\"\"Algorithm {i} processing.\"\"\"\n")
|
||||
code_lines.append(f" result = 0\n")
|
||||
code_lines.append(f" for j in range({i * 10}):\n")
|
||||
code_lines.append(f" result += x * y + j\n")
|
||||
code_lines.append(f" return result\n\n")
|
||||
code_text = "".join(code_lines)
|
||||
# Pad/truncate to target
|
||||
while len(code_text) < TARGET_PROMPT_BYTES:
|
||||
code_text += "# Additional algorithm block\n"
|
||||
code_text += f"def extra_{len(code_text)//100}():\n"
|
||||
code_text += " x = 1\n"
|
||||
code_text += " y = 2\n"
|
||||
code_text += " return x + y\n\n"
|
||||
code_text = code_text[:TARGET_PROMPT_BYTES]
|
||||
|
||||
# Prose prompt: historical/scientific/philosophical text
|
||||
prose_text = ""
|
||||
topics = [
|
||||
"The Renaissance brought profound transformation to European civilization. Artists and scholars pushed the boundaries of human expression.",
|
||||
"Quantum mechanics describes the behavior of matter and energy at atomic scales. The uncertainty principle limits measurement precision.",
|
||||
"Ethical philosophy grapples with questions of right and wrong. Utilitarianism judges actions by their consequences and the happiness they produce.",
|
||||
"Mountains rise like ancient sentinels, their peaks lost in clouds that swirl and dance across the sky above the valleys below.",
|
||||
"The ocean whispers secrets to the shore, each wave carrying fragments of distant lands across the water before retreating back to sea.",
|
||||
]
|
||||
for _ in range(500):
|
||||
prose_text += f"{topics[len(prose_text) % len(topics)]} "
|
||||
prose_text = prose_text[:TARGET_PROMPT_BYTES]
|
||||
|
||||
code_prompt_file.write_text(code_text)
|
||||
prose_prompt_file.write_text(prose_text)
|
||||
print(f" Code: {code_prompt_file.stat().st_size:,} bytes")
|
||||
print(f" Prose: {prose_prompt_file.stat().st_size:,} bytes")
|
||||
|
||||
print("=" * 60)
|
||||
print(" N-gram Mod Benchmark — frank-dragoon")
|
||||
print(f" STRIX ROCmFP4 · 64K context · 16K prompts")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
port_base = 7772
|
||||
port_ngram = 7773
|
||||
|
||||
tests = [
|
||||
("code", "none", port_base, code_prompt_file),
|
||||
("prose", "none", port_ngram, prose_prompt_file),
|
||||
("code", "ngram-mod", port_base, code_prompt_file),
|
||||
("prose", "ngram-mod", port_ngram, prose_prompt_file),
|
||||
]
|
||||
|
||||
for prompt_type, spec_type, port, pfile in tests:
|
||||
print(f"\n=== {spec_type} ({prompt_type}) ===")
|
||||
print(f" File: {pfile} ({pfile.stat().st_size:,} bytes)")
|
||||
print(f" Spec: {spec_type}")
|
||||
if spec_type == "ngram-mod":
|
||||
print(f" n_max={NGRAM_N_MAX}, n_min={NGRAM_N_MIN}")
|
||||
|
||||
proc = start_server(port, spec_type)
|
||||
|
||||
try:
|
||||
# Warm-up
|
||||
print(" Warm-up...")
|
||||
run_test("Hello test", spec_type, port)
|
||||
|
||||
# Full prompt
|
||||
print(f" Sending {TARGET_PROMPT_TOKENS} token prompt...")
|
||||
with open(pfile) as f:
|
||||
prompt_text = f.read()
|
||||
result = run_test(prompt_text, spec_type, port)
|
||||
|
||||
tps = result["prompt_per_second"]
|
||||
print(f" Tokens: {result['prompt_tokens']}")
|
||||
print(f" Prompt: {result['prompt_ms']:.0f}ms")
|
||||
print(f" TPS: {tps:.1f} tok/s")
|
||||
print(f" Total: {result['total_time_s']:.1f}s")
|
||||
|
||||
results.append({
|
||||
"prompt_type": prompt_type,
|
||||
"spec_type": spec_type,
|
||||
"prompt_tokens": result["prompt_tokens"],
|
||||
"prompt_ms": result["prompt_ms"],
|
||||
"tps": tps,
|
||||
"total_time_s": result["total_time_s"],
|
||||
})
|
||||
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print(" RESULTS SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
for ptype in ["code", "prose"]:
|
||||
baseline = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "none"), None)
|
||||
ngram = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "ngram-mod"), None)
|
||||
if baseline and ngram:
|
||||
gain = ((ngram["tps"] - baseline["tps"]) / baseline["tps"]) * 100 if baseline["tps"] > 0 else 0
|
||||
print(f"\n{ptype.upper()} PROMPT:")
|
||||
print(f" Baseline TPS: {baseline['tps']:.1f} tok/s ({baseline['prompt_ms']:.0f}ms)")
|
||||
print(f" N-gram TPS: {ngram['tps']:.1f} tok/s ({ngram['prompt_ms']:.0f}ms)")
|
||||
print(f" Speedup: {gain:+.1f}%")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,304 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Benchmark n-gram speculative decoding vs no-spec for frank-dragoon.
|
||||
#
|
||||
# This tests prompt-processing throughput at 64k context with two distinct
|
||||
# prompts: one code-heavy and one prose-heavy. Uses llama-server on scratch
|
||||
# ports with the OpenAI-compatible API to measure prompt_eval_duration_ms.
|
||||
#
|
||||
# Run on Dankclaw after stopping the production service:
|
||||
# sudo systemctl stop frank-dragoon
|
||||
# ./scripts/bench-ngram-mod.sh
|
||||
# sudo systemctl start frank-dragoon
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
fi
|
||||
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}"
|
||||
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}"
|
||||
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={}}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=4096}"
|
||||
: "${CTX_SIZE:=65536}"
|
||||
: "${NUM_THREADS:=16}"
|
||||
: "${THREAD_BATCH:=32}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${FIT:=off}"
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_P:=0.8}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
HOSTNAME=$(hostname 2>/dev/null || echo "dankclaw")
|
||||
|
||||
# Prompt files (generated by generate-prompts.py or passed via args)
|
||||
PROMPT_CODE="${1:-}"
|
||||
PROMPT_PROSE="${2:-}"
|
||||
|
||||
if [[ -z "$PROMPT_CODE" || -z "$PROMPT_PROSE" ]]; then
|
||||
echo "ERROR: Need two prompt file paths."
|
||||
echo "Usage: $0 <prompt_code.txt> <prompt_prose.txt>"
|
||||
echo "Generate with: python3 scripts/generate-prompts.py"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -r "$MODEL" ]]; then
|
||||
echo "missing or unreadable model: $MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_DIR="$(cd -- "$(dirname -- "$LLAMA_BIN")" && pwd)"
|
||||
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
|
||||
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
# Scratch ports
|
||||
PORT_NGRAM=7771
|
||||
PORT_BASELINE=7772
|
||||
|
||||
cleanup() {
|
||||
echo "Cleaning up servers..."
|
||||
# Kill any leftover llama-server on our ports
|
||||
for port in $PORT_NGRAM $PORT_BASELINE; do
|
||||
fuser -k "$port"/tcp 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_server() {
|
||||
local port=$1
|
||||
local max_wait=120
|
||||
local elapsed=0
|
||||
while (( elapsed < max_wait )); do
|
||||
if curl -s "http://127.0.0.1:$port/health" | grep -q '"running"'; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
(( elapsed++ ))
|
||||
done
|
||||
echo "ERROR: Server on port $port did not start within ${max_wait}s" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
get_prompt_tokens() {
|
||||
local file=$1
|
||||
# Count tokens approximately (1 token ~ 4 chars for English/code)
|
||||
wc -c < "$file" | awk '{printf "%d", $1/4}'
|
||||
}
|
||||
|
||||
run_single_test() {
|
||||
local port=$1
|
||||
local spec_type="$2"
|
||||
local prompt_file=$3
|
||||
local label=$4
|
||||
local ngram_n_max="${5:-24}"
|
||||
local ngram_n_min="${6:-4}"
|
||||
|
||||
echo "=== $label ==="
|
||||
echo " spec-type: $spec_type"
|
||||
echo " ngram-mod n-max: $ngram_n_max (if enabled)"
|
||||
echo " ngram-mod n-min: $ngram_n_min (if enabled)"
|
||||
echo " prompt: $prompt_file ($(wc -c < "$prompt_file") bytes)"
|
||||
|
||||
# Build command
|
||||
local cmd=(
|
||||
/usr/bin/env
|
||||
ROCM_PATH="$ROCM_PATH"
|
||||
HIP_VISIBLE_DEVICES=0
|
||||
LD_LIBRARY_PATH="$LIBRARY_PATH"
|
||||
HSA_OVERRIDE_GFX_VERSION="${HSA_OVERRIDE_GFX_VERSION:-11.5.1}"
|
||||
"$LLAMA_BIN"
|
||||
--model "$MODEL"
|
||||
--chat-template-file "$CHAT_TEMPLATE_FILE"
|
||||
--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS"
|
||||
--host 127.0.0.1
|
||||
--port "$port"
|
||||
--jinja
|
||||
--device ROCm0
|
||||
--gpu-layers 999
|
||||
--parallel 1
|
||||
--ctx-size "$CTX_SIZE"
|
||||
--batch-size "$BATCH_SIZE"
|
||||
--ubatch-size "$UBATCH_SIZE"
|
||||
--fit "$FIT"
|
||||
--flash-attn on
|
||||
--cache-type-k "$CACHE_TYPE_K"
|
||||
--cache-type-v "$CACHE_TYPE_V"
|
||||
--threads "$NUM_THREADS"
|
||||
--threads-batch "$THREAD_BATCH"
|
||||
--cache-prompt
|
||||
--cache-idle-slots
|
||||
--metrics
|
||||
--temp "$TEMPERATURE"
|
||||
--top-p "$TOP_P"
|
||||
--top-k "$TOP_K"
|
||||
--min-p "$MIN_P"
|
||||
--reasoning "$REASONING"
|
||||
--reasoning-format "$REASONING_FORMAT"
|
||||
--reasoning-budget -1
|
||||
)
|
||||
|
||||
# Add spec flags
|
||||
if [[ "$spec_type" == "ngram-mod" ]]; then
|
||||
cmd+=(
|
||||
--spec-type "ngram-mod"
|
||||
--spec-ngram-mod-n-max "$ngram_n_max"
|
||||
--spec-ngram-mod-n-min "$ngram_n_min"
|
||||
)
|
||||
else
|
||||
cmd+=(
|
||||
--spec-type "none"
|
||||
)
|
||||
fi
|
||||
|
||||
# Start server with stderr to log
|
||||
local log_file="/tmp/ngram-bench-${label//[^a-zA-Z0-9]/-}.log"
|
||||
echo " Starting server (log: $log_file)..."
|
||||
|
||||
"${cmd[@]}" 2> "$log_file" &
|
||||
local server_pid=$!
|
||||
|
||||
if ! wait_for_server "$port"; then
|
||||
echo "ERROR: Server failed to start" >&2
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Server ready."
|
||||
|
||||
# Send prompt via API
|
||||
echo " Sending prompt..."
|
||||
local prompt_text
|
||||
prompt_text=$(cat "$prompt_file")
|
||||
|
||||
local response
|
||||
response=$(curl -s -X POST "http://127.0.0.1:$port/v1/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"model\": \"frank-dragoon\",
|
||||
\"prompt\": \"$prompt_text\",
|
||||
\"max_tokens\": 128,
|
||||
\"temperature\": 0,
|
||||
\"stream\": false
|
||||
}")
|
||||
|
||||
# Extract metrics
|
||||
local prompt_tokens prompt_eval_duration_ms total_duration_ms
|
||||
prompt_tokens=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_count'])" 2>/dev/null || echo "0")
|
||||
prompt_eval_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_duration_ms'])" 2>/dev/null || echo "0")
|
||||
total_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['total_duration_ms'])" 2>/dev/null || echo "0")
|
||||
|
||||
# Calculate throughput
|
||||
local prompt_tps=0
|
||||
if [[ "$prompt_eval_duration_ms" -gt 0 ]]; then
|
||||
prompt_tps=$(python3 -c "print(f'{($prompt_tokens / $prompt_eval_duration_ms) * 1000:.1f}')")
|
||||
fi
|
||||
|
||||
echo " Prompt tokens: $prompt_tokens"
|
||||
echo " Prompt eval: ${prompt_eval_duration_ms}ms"
|
||||
echo " Prompt TPS: $prompt_tps tok/s"
|
||||
echo " Total duration: ${total_duration_ms}ms"
|
||||
|
||||
# Kill server
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
echo " Done."
|
||||
echo "---"
|
||||
|
||||
# Output structured results for parsing
|
||||
echo "RESULT|$label|$prompt_file|$prompt_tokens|$prompt_eval_duration_ms|$total_duration_ms|$prompt_tps"
|
||||
}
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ N-gram Mod Benchmark — frank-dragoon ║"
|
||||
echo "║ STRIX ROCmFP4 · 64k context · two prompts ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Host: $HOSTNAME"
|
||||
echo "Model: $MODEL"
|
||||
echo "Build: $(basename "$LLAMA_BIN")"
|
||||
echo "Context: $CTX_SIZE"
|
||||
echo ""
|
||||
|
||||
# Run baseline (no spec)
|
||||
echo "=== BASELINE (no speculative decoding) ==="
|
||||
echo ""
|
||||
|
||||
BASELINE_CODE_RESULT=$(run_single_test $PORT_BASELINE "none" "$PROMPT_CODE" "Baseline-Code" 24 4)
|
||||
BASELINE_PROSE_RESULT=$(run_single_test $PORT_NGRAM "none" "$PROMPT_PROSE" "Baseline-Prose" 24 4)
|
||||
|
||||
echo ""
|
||||
echo "=== NGRAM-MOD (speculative decoding enabled) ==="
|
||||
echo ""
|
||||
|
||||
# Run n-gram mod
|
||||
NGRAM_CODE_RESULT=$(run_single_test $PORT_NGRAM "ngram-mod" "$PROMPT_CODE" "Ngram-Code" 24 4)
|
||||
NGRAM_PROSE_RESULT=$(run_single_test $PORT_BASELINE "ngram-mod" "$PROMPT_PROSE" "Ngram-Prose" 24 4)
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ RESULTS SUMMARY ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Parse and display results
|
||||
python3 << 'PYEOF'
|
||||
import sys
|
||||
|
||||
results = []
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line.startswith("RESULT|"):
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) != 8:
|
||||
continue
|
||||
label, prompt_file, prompt_tokens, prompt_eval_ms, total_ms, tps = parts[1], parts[2], int(parts[3]), int(parts[4]), int(parts[5]), float(parts[6])
|
||||
results.append({
|
||||
'label': label,
|
||||
'prompt_file': prompt_file,
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'prompt_eval_ms': prompt_eval_ms,
|
||||
'total_ms': total_ms,
|
||||
'tps': tps
|
||||
})
|
||||
|
||||
# Compare baseline vs ngram for each prompt type
|
||||
for code_result in results:
|
||||
if not code_result['label'].startswith('Ngram-'):
|
||||
continue
|
||||
prompt_type = code_result['label'].replace('Ngram-', '').replace('Ngram', 'Baseline').replace('-', '-')
|
||||
baseline = next((r for r in results if r['label'] == 'Baseline-' + prompt_type), None)
|
||||
if not baseline:
|
||||
continue
|
||||
|
||||
spec = code_result['label'] == 'Ngram-' + prompt_type
|
||||
|
||||
print(f"Prompt: {prompt_type}")
|
||||
print(f" Tokens: {code_result['prompt_tokens']}")
|
||||
if spec:
|
||||
print(f" Spec: ngram-mod (n_max=24, n_min=4)")
|
||||
print(f" Prompt: {code_result['prompt_eval_ms']}ms | {code_result['tps']:>10.1f} tok/s")
|
||||
baseline_tps = baseline['tps']
|
||||
if baseline_tps > 0:
|
||||
improvement = ((code_result['tps'] - baseline_tps) / baseline_tps) * 100
|
||||
print(f" Speedup: {improvement:+.1f}% vs baseline ({baseline_tps:.1f} tok/s)")
|
||||
else:
|
||||
print(f" Spec: none (baseline)")
|
||||
print(f" Prompt: {baseline['prompt_eval_ms']}ms | {baseline['tps']:>10.1f} tok/s")
|
||||
print()
|
||||
PYEOF
|
||||
|
||||
echo ""
|
||||
echo "Raw data lines above (RESULT|... format) can be parsed for automation."
|
||||
echo ""
|
||||
@@ -1,229 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark decode throughput across speculative decoding configs.
|
||||
|
||||
Sweeps 4 configs: none, ngram-mod, draft-mtp, draft-mtp+ngram-mod.
|
||||
Each config generates 16k tokens from a minimal prompt at temp 0.
|
||||
|
||||
Runs ON the target host (Dankclaw). Service management uses local sudo.
|
||||
Usage:
|
||||
python3 bench-spec-decode.py [--max-tokens N] [--host H] [--port P]
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
SERVICE = "frank-dragoon"
|
||||
ENV_FILE = "/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
|
||||
ALIAS = "frank-dragoon"
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 7770
|
||||
DEFAULT_MAX_TOKENS = 16384
|
||||
HEALTH_TIMEOUT = 1200 # 20 min for cold load
|
||||
HEALTH_INTERVAL = 5
|
||||
|
||||
PROMPT = (
|
||||
"Write a comprehensive, detailed essay about the complete history of "
|
||||
"computing, from the earliest mechanical calculators through modern AI. "
|
||||
"Cover every major era in detail, including key figures, machines, and "
|
||||
"breakthroughs. Be as thorough as possible."
|
||||
)
|
||||
|
||||
CONFIGS = [
|
||||
("none", "none"),
|
||||
("ngram-mod", "ngram-mod"),
|
||||
("draft-mtp", "draft-mtp"),
|
||||
("draft-mtp+ngram-mod", "draft-mtp,ngram-mod"),
|
||||
]
|
||||
|
||||
|
||||
def local(cmd):
|
||||
"""Run a command locally (the script runs on the target host)."""
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120)
|
||||
if r.returncode != 0:
|
||||
print(f" WARNING: {cmd} -> exit {r.returncode}: {r.stderr.strip()}",
|
||||
file=sys.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def sed_spec_type(value):
|
||||
local(f"sudo sed -i 's/^SPEC_TYPE=.*/SPEC_TYPE={value}/' {ENV_FILE}")
|
||||
|
||||
|
||||
def wait_health(host, port, timeout=HEALTH_TIMEOUT):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
resp = urllib.request.urlopen(
|
||||
f"http://{host}:{port}/health", timeout=10
|
||||
)
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, ConnectionError,
|
||||
OSError):
|
||||
pass
|
||||
time.sleep(HEALTH_INTERVAL)
|
||||
return False
|
||||
|
||||
|
||||
def benchmark(host, port, alias, max_tokens):
|
||||
body = json.dumps({
|
||||
"model": alias,
|
||||
"messages": [{"role": "user", "content": PROMPT}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0,
|
||||
"stream": False,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://{host}:{port}/v1/chat/completions",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
t0 = time.time()
|
||||
resp = urllib.request.urlopen(req, timeout=1200)
|
||||
wall = time.time() - t0
|
||||
data = json.loads(resp.read())
|
||||
return data, wall
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--host", default=DEFAULT_HOST)
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
ap.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
|
||||
args = ap.parse_args()
|
||||
|
||||
# Read original SPEC_TYPE from the env file
|
||||
r = local(f"grep '^SPEC_TYPE=' {ENV_FILE}")
|
||||
original_spec = r.stdout.strip()
|
||||
orig_val = (original_spec.split("=", 1)[1]
|
||||
if "=" in original_spec else "draft-mtp,ngram-mod")
|
||||
print(f"Original: {original_spec}")
|
||||
print(f"Max tokens: {args.max_tokens}")
|
||||
print(f"Configs: {[c[0] for c in CONFIGS]}")
|
||||
print()
|
||||
|
||||
# Stop + mask the production service
|
||||
print("Stopping + masking production service...")
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
time.sleep(2)
|
||||
|
||||
results = []
|
||||
for name, spec_type in CONFIGS:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Config: {name} (SPEC_TYPE={spec_type})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Set SPEC_TYPE
|
||||
sed_spec_type(spec_type)
|
||||
print(f"Set SPEC_TYPE={spec_type}")
|
||||
|
||||
# Start the service
|
||||
local(f"sudo systemctl unmask {SERVICE}")
|
||||
local(f"sudo systemctl start {SERVICE}")
|
||||
print("Starting service...")
|
||||
|
||||
# Wait for health
|
||||
print(f"Waiting for health (up to {HEALTH_TIMEOUT}s)...")
|
||||
if not wait_health(args.host, args.port):
|
||||
print(f"FAILED: service did not become healthy for {name}")
|
||||
results.append({
|
||||
"config": name, "spec_type": spec_type,
|
||||
"error": "health timeout",
|
||||
})
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
continue
|
||||
|
||||
print("Healthy. Running benchmark...")
|
||||
|
||||
# Run benchmark
|
||||
try:
|
||||
data, wall = benchmark(
|
||||
args.host, args.port, ALIAS, args.max_tokens
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
results.append({
|
||||
"config": name, "spec_type": spec_type,
|
||||
"error": str(e),
|
||||
})
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
continue
|
||||
|
||||
timings = data.get("timings", {})
|
||||
usage = data.get("usage", {})
|
||||
pred_n = timings.get("predicted_n", usage.get("completion_tokens", 0))
|
||||
pred_ms = timings.get("predicted_ms", 0)
|
||||
pred_ps = timings.get("predicted_per_second", 0)
|
||||
prompt_n = timings.get("prompt_n", usage.get("prompt_tokens", 0))
|
||||
prompt_ms = timings.get("prompt_ms", 0)
|
||||
|
||||
print(f" Generated: {pred_n} tokens")
|
||||
print(f" Wall time: {wall:.1f}s")
|
||||
print(f" Decode: {pred_ps:.2f} tok/s")
|
||||
print(f" Prefill: {prompt_n} tokens in {prompt_ms:.0f}ms"
|
||||
f" ({prompt_n / max(prompt_ms, 1) * 1000:.1f} tok/s)")
|
||||
|
||||
results.append({
|
||||
"config": name,
|
||||
"spec_type": spec_type,
|
||||
"predicted_tokens": pred_n,
|
||||
"predicted_ms": pred_ms,
|
||||
"predicted_per_second": pred_ps,
|
||||
"prompt_tokens": prompt_n,
|
||||
"prompt_ms": prompt_ms,
|
||||
"wall_time": wall,
|
||||
})
|
||||
|
||||
# Stop + mask between configs
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
time.sleep(2)
|
||||
|
||||
# Restore original SPEC_TYPE
|
||||
sed_spec_type(orig_val)
|
||||
print(f"\nRestoring SPEC_TYPE={orig_val}")
|
||||
print("Unmasking + starting production service...")
|
||||
local(f"sudo systemctl unmask {SERVICE}")
|
||||
local(f"sudo systemctl start {SERVICE}")
|
||||
|
||||
# Health check
|
||||
if wait_health(args.host, args.port):
|
||||
print("Production service healthy.")
|
||||
else:
|
||||
print("WARNING: production service not healthy after restore!")
|
||||
|
||||
# Print results table
|
||||
print(f"\n{'='*70}")
|
||||
print("RESULTS")
|
||||
print(f"{'='*70}")
|
||||
print(f"{'Config':<30} {'Tokens':>8} {'Wall(s)':>10} {'Decode tok/s':>14} "
|
||||
f"{'Error':>20}")
|
||||
print(f"{'-'*30} {'-'*8} {'-'*10} {'-'*14} {'-'*20}")
|
||||
for r in results:
|
||||
if "error" in r:
|
||||
print(f"{r['config']:<30} {'':>8} {'':>10} {'':>14} "
|
||||
f"{r['error']:>20}")
|
||||
else:
|
||||
print(f"{r['config']:<30} {r['predicted_tokens']:>8} "
|
||||
f"{r['wall_time']:>10.1f} {r['predicted_per_second']:>14.2f} "
|
||||
f"{'':>20}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# Save JSON results
|
||||
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||
out = f"/tmp/bench-spec-decode-{ts}.json"
|
||||
with open(out, "w") as f:
|
||||
json.dump({"results": results, "max_tokens": args.max_tokens,
|
||||
"timestamp": ts}, f, indent=2)
|
||||
print(f"Results saved: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bench-vllm-corpus.py — build a code-shaped benchmark corpus from the repo.
|
||||
|
||||
Why this exists: `vllm bench serve --dataset-name random` produces synthetic
|
||||
token soup, and speculative decoding accepts it nothing like real code — measured
|
||||
on frank-medium, random prompts accepted 2.9-3.6 tokens/update against the ~5.8
|
||||
the upstream reference reports on a code mix, which makes the random corpus
|
||||
under-report throughput by a wide margin and would pick the wrong SPEC depth.
|
||||
|
||||
The prompts are the shape agents actually send: a repository artifact plus an
|
||||
instruction that requires reading it. Output length is fixed at bench time
|
||||
(`--custom-output-len`), so the corpus carries prompts only.
|
||||
|
||||
Usage:
|
||||
bench-vllm-corpus.py [repo-root] [out.jsonl]
|
||||
|
||||
Then feed it to the endpoint (see scripts/bench-vllm-serve.sh, which passes the
|
||||
tokenizer the client needs and salts prompts so no run inherits a warm prefix
|
||||
cache). Methodology and the numbers these corpora produced are in
|
||||
serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
REPO = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "/home/dank/serverAppConfigs")
|
||||
OUT = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else "/home/dank/bench/corpus-code.jsonl")
|
||||
|
||||
TARGETS = [
|
||||
"scripts/deploy-remote.sh",
|
||||
"scripts/deploy-plan.py",
|
||||
"scripts/deploy.sh",
|
||||
"dankserver/frank-medium/radiance-entrypoint.sh",
|
||||
"dankserver/frank-medium/quadlet/frank-medium-a.container",
|
||||
"dankserver/frank-medium/proxy/affinity-proxy.py",
|
||||
"dankserver/frank-medium/proxy/routing.py",
|
||||
"tests/frank_medium_quadlet.bats",
|
||||
"tests/deployment_scoping.bats",
|
||||
"dankclaw/hermes/quadlet/hermes.container",
|
||||
"dankclaw/anubis/quadlet/anubis.container",
|
||||
"shared/prometheus/prometheus.yml",
|
||||
"shared/prometheus/rules/llm-fleet.yml",
|
||||
"shared/loki/loki-config.yaml",
|
||||
"dankclaw/compose.yaml",
|
||||
"dankserver/copyparty/quadlet/copyparty.container",
|
||||
]
|
||||
|
||||
PROMPTS = [
|
||||
"Read this repository artifact and summarise what it does, then list every "
|
||||
"assumption it makes about the host it runs on.\n\n```\n{body}\n```",
|
||||
"Here is a file from our homelab repository. Identify any bug, race or "
|
||||
"missing error path, and show the minimal patch that fixes it.\n\n```\n{body}\n```",
|
||||
"Explain this configuration to a new operator, then rewrite it with "
|
||||
"comments that state the constraint each line protects.\n\n```\n{body}\n```",
|
||||
"This file changed in a recent deploy. Write the changelog entry and a test "
|
||||
"that would fail if the change regressed.\n\n```\n{body}\n```",
|
||||
]
|
||||
|
||||
MAX_LINES = 220
|
||||
|
||||
|
||||
def main() -> int:
|
||||
samples = []
|
||||
for i, rel in enumerate(TARGETS):
|
||||
path = REPO / rel
|
||||
if not path.is_file():
|
||||
continue
|
||||
body = "\n".join(path.read_text(errors="replace").splitlines()[:MAX_LINES])
|
||||
samples.append({"prompt": PROMPTS[i % len(PROMPTS)].format(body=body)})
|
||||
if not samples:
|
||||
print(f"no corpus sources found under {REPO}", file=sys.stderr)
|
||||
return 1
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
with OUT.open("w") as fh:
|
||||
for sample in samples:
|
||||
fh.write(json.dumps(sample) + "\n")
|
||||
print(f"wrote {len(samples)} prompts to {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
# bench-vllm-serve.sh — concurrency sweep against a vLLM endpoint.
|
||||
#
|
||||
# Usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]
|
||||
# e.g. bench-vllm-serve.sh baseline 7770 1 4 8
|
||||
#
|
||||
# Runs `vllm bench serve` from inside the serving image (so the client matches
|
||||
# the server build) through an OpenAI-compatible endpoint, on the code corpus
|
||||
# scripts/bench-vllm-corpus.py writes.
|
||||
#
|
||||
# Two traps this script exists to encode, both of which produced silent garbage
|
||||
# during the 2026-09-16 frank-medium work:
|
||||
#
|
||||
# 1. The tokenizer must be passed explicitly and /models mounted. With only
|
||||
# `--model frank-medium` the client tries to resolve that alias on
|
||||
# HuggingFace and dies before sending a request — a run that looks like a
|
||||
# benchmark and reports nothing.
|
||||
# 2. `--ignore-eos` is deliberate: it measures decode throughput at a fixed
|
||||
# output length. Drop it when you care about real stop behaviour, but then
|
||||
# do not compare the numbers with anything measured under it.
|
||||
#
|
||||
# Also note the corpora are code-shaped on purpose (see the generator's header):
|
||||
# synthetic random prompts misreport speculative acceptance badly.
|
||||
#
|
||||
# Methodology, and the numbers these runs produced on 2x R9700, are in
|
||||
# serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
|
||||
set -u
|
||||
|
||||
TAG=${1:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]}
|
||||
PORT=${2:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]}
|
||||
shift 2
|
||||
CONCS=("$@")
|
||||
[ "${#CONCS[@]}" -eq 0 ] && CONCS=(1 4 8)
|
||||
|
||||
IMG=${IMG:-localhost/radiance-vllm:0.9.3}
|
||||
MODELS_DIR=${MODELS_DIR:-/home/dank/models}
|
||||
TOK=${TOK:-/models/Qwen3.8-27B-Uncensored-MXFP4-awq}
|
||||
BENCH_DIR=${BENCH_DIR:-/home/dank/bench}
|
||||
CORPUS=${CORPUS:-$BENCH_DIR/corpus-code.jsonl}
|
||||
OUT=${OUT:-$BENCH_DIR/$TAG}
|
||||
mkdir -p "$OUT"
|
||||
|
||||
if [ ! -f "$CORPUS" ]; then
|
||||
echo "corpus $CORPUS is missing; run scripts/bench-vllm-corpus.py first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for c in "${CONCS[@]}"; do
|
||||
echo "== $TAG conc=$c (port $PORT)"
|
||||
podman run --rm --network=host -v "$MODELS_DIR":/models:ro -v "$BENCH_DIR":/bench:ro \
|
||||
-e HF_HUB_OFFLINE=1 --entrypoint bash "$IMG" -lc "
|
||||
vllm bench serve --backend openai-chat \
|
||||
--base-url http://127.0.0.1:$PORT --endpoint /v1/chat/completions \
|
||||
--model frank-medium --tokenizer $TOK \
|
||||
--dataset-name custom --dataset-path /bench/$(basename "$CORPUS") \
|
||||
--custom-output-len 512 --num-prompts 16 --max-concurrency $c \
|
||||
--disable-shuffle --seed 0 --ignore-eos \
|
||||
--percentile-metrics ttft,tpot --metric-percentiles 50,99 \
|
||||
--disable-tqdm 2>&1 | grep -E 'Successful requests|Benchmark duration|Output token throughput|Total Token throughput|Mean TTFT|P50 TTFT|P50 TPOT|Acceptance rate|Acceptance length'
|
||||
" 2>&1 | sed 's/^/ /'
|
||||
echo
|
||||
done
|
||||
echo "results for $TAG written under $OUT"
|
||||
@@ -1,453 +0,0 @@
|
||||
{%- set template_version = "qwen3.8-froggeric-v22.4.0" %}
|
||||
{%- set _tool_format = tool_call_format if tool_call_format is defined else 'xml' %}
|
||||
{%- set image_count = namespace(value=0) %}
|
||||
{%- set video_count = namespace(value=0) %}
|
||||
{%- set add_vision_id = add_vision_id if add_vision_id is defined else false %}
|
||||
{%- set enable_thinking = enable_thinking if enable_thinking is defined else true %}
|
||||
{%- set auto_disable_thinking_with_tools = auto_disable_thinking_with_tools if auto_disable_thinking_with_tools is defined else false %}
|
||||
{%- if preserve_reasoning is defined and preserve_reasoning is not none %}
|
||||
{%- set _preserve_thinking = preserve_reasoning %}
|
||||
{%- elif preserve_thinking is defined and preserve_thinking is not none %}
|
||||
{%- set _preserve_thinking = preserve_thinking %}
|
||||
{%- else %}
|
||||
{%- set _preserve_thinking = true %}
|
||||
{%- endif %}
|
||||
{%- set max_tool_arg_chars = max_tool_arg_chars if max_tool_arg_chars is defined else 0 %}
|
||||
{%- set max_tool_response_chars = max_tool_response_chars if max_tool_response_chars is defined else 0 %}
|
||||
{%- set _default_reasoning_effort = 'medium' %}
|
||||
{%- set _has_tools = (tools is defined and tools and tools is iterable and tools is not mapping) %}
|
||||
{%- set _effort_raw = (reasoning_effort | string | lower) if reasoning_effort is defined and reasoning_effort is not none else _default_reasoning_effort %}
|
||||
{%- set _initial_thinking = enable_thinking %}
|
||||
{%- if _effort_raw in ('none', 'off') %}
|
||||
{%- set _initial_thinking = false %}
|
||||
{%- set _initial_effort = 'medium' %}
|
||||
{%- elif _effort_raw in ('minimal', 'low') %}
|
||||
{%- set _initial_effort = 'low' %}
|
||||
{%- elif _effort_raw in ('high', 'xhigh', 'max', 'ultracode', 'extreme') %}
|
||||
{%- set _initial_effort = 'xhigh' %}
|
||||
{%- else %}
|
||||
{%- set _initial_effort = 'medium' %}
|
||||
{%- endif %}
|
||||
{%- set ns_state = namespace(thinking=_initial_thinking, effort=_initial_effort) %}
|
||||
{%- if auto_disable_thinking_with_tools and _has_tools %}
|
||||
{%- set ns_state.thinking = false %}
|
||||
{%- endif %}
|
||||
{%- for msg in messages %}
|
||||
{%- if msg.role == 'system' or msg.role == 'developer' or msg.role == 'user' %}
|
||||
{%- if msg.content is string %}
|
||||
{%- if '<|think_off|>' in msg.content %}
|
||||
{%- set ns_state.thinking = false %}
|
||||
{%- elif '<|think_on|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- elif '<|think_xhigh|>' in msg.content or '<|think_high|>' in msg.content or '<|think_ultracode|>' in msg.content or '<|think_extreme|>' in msg.content or '<|think_max|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'xhigh' %}
|
||||
{%- elif '<|think_low|>' in msg.content or '<|think_minimal|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'low' %}
|
||||
{%- elif '<|think_medium|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'medium' %}
|
||||
{%- endif %}
|
||||
{%- elif msg.content is iterable and msg.content is not mapping %}
|
||||
{%- for item in msg.content %}
|
||||
{%- if item is string %}
|
||||
{%- set _item_text = item %}
|
||||
{%- elif item is mapping and 'text' in item and item.text is string %}
|
||||
{%- set _item_text = item.text %}
|
||||
{%- else %}
|
||||
{%- set _item_text = '' %}
|
||||
{%- endif %}
|
||||
{%- if _item_text %}
|
||||
{%- if '<|think_off|>' in _item_text %}
|
||||
{%- set ns_state.thinking = false %}
|
||||
{%- elif '<|think_on|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- elif '<|think_xhigh|>' in _item_text or '<|think_high|>' in _item_text or '<|think_ultracode|>' in _item_text or '<|think_extreme|>' in _item_text or '<|think_max|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'xhigh' %}
|
||||
{%- elif '<|think_low|>' in _item_text or '<|think_minimal|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'low' %}
|
||||
{%- elif '<|think_medium|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'medium' %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- set reasoning_instructions = '' %}
|
||||
{%- if ns_state.thinking %}
|
||||
{%- if ns_state.effort == 'xhigh' %}
|
||||
{%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
|
||||
{%- elif ns_state.effort == 'low' %}
|
||||
{%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
|
||||
{%- if content is string %}
|
||||
{{- content }}
|
||||
{%- elif content is iterable and content is not mapping %}
|
||||
{%- for item in content %}
|
||||
{%- if item is mapping %}
|
||||
{%- if item.type == 'image' or 'image' in item or 'image_url' in item %}
|
||||
{%- if is_system_content %}
|
||||
{{- raise_exception('System message cannot contain images.') }}
|
||||
{%- endif %}
|
||||
{%- if do_vision_count %}
|
||||
{%- set image_count.value = image_count.value + 1 %}
|
||||
{%- endif %}
|
||||
{%- if add_vision_id %}
|
||||
{{- 'Picture ' ~ image_count.value ~ ': ' }}
|
||||
{%- endif %}
|
||||
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
|
||||
{%- elif item.type == 'video' or 'video' in item %}
|
||||
{%- if is_system_content %}
|
||||
{{- raise_exception('System message cannot contain videos.') }}
|
||||
{%- endif %}
|
||||
{%- if do_vision_count %}
|
||||
{%- set video_count.value = video_count.value + 1 %}
|
||||
{%- endif %}
|
||||
{%- if add_vision_id %}
|
||||
{{- 'Video ' ~ video_count.value ~ ': ' }}
|
||||
{%- endif %}
|
||||
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
|
||||
{%- elif 'text' in item %}
|
||||
{{- item.text }}
|
||||
{%- else %}
|
||||
{{- raise_exception('Unexpected item type in content.') }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- item | string }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- elif content is none or content is undefined %}
|
||||
{{- '' }}
|
||||
{%- else %}
|
||||
{{- raise_exception('Unexpected content type.') }}
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
{%- if not messages %}
|
||||
{{- raise_exception('No messages provided.') }}
|
||||
{%- endif %}
|
||||
{%- set head = namespace(count=0, seen_non_system=false) %}
|
||||
{%- for message in messages %}
|
||||
{%- set _is_sys = (message.role == 'system' or message.role == 'developer') %}
|
||||
{%- if _is_sys and not head.seen_non_system %}
|
||||
{%- set head.count = head.count + 1 %}
|
||||
{%- else %}
|
||||
{%- set head.seen_non_system = true %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- set sys_state = namespace(content='') %}
|
||||
{%- for message in messages[:head.count] %}
|
||||
{%- set _part = render_content(message.content, false, true) | trim %}
|
||||
{%- if '<|think_off|>' in _part %}{%- set _part = _part.split('<|think_off|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_on|>' in _part %}{%- set _part = _part.split('<|think_on|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_xhigh|>' in _part %}{%- set _part = _part.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_high|>' in _part %}{%- set _part = _part.split('<|think_high|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_ultracode|>' in _part %}{%- set _part = _part.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_extreme|>' in _part %}{%- set _part = _part.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_max|>' in _part %}{%- set _part = _part.split('<|think_max|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_medium|>' in _part %}{%- set _part = _part.split('<|think_medium|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_low|>' in _part %}{%- set _part = _part.split('<|think_low|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_minimal|>' in _part %}{%- set _part = _part.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
|
||||
{%- if _part %}
|
||||
{%- if sys_state.content %}
|
||||
{%- set sys_state.content = sys_state.content ~ '\n\n' ~ _part %}
|
||||
{%- else %}
|
||||
{%- set sys_state.content = _part %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- set _sc = sys_state.content %}
|
||||
{%- set _msgs = messages[head.count:] %}
|
||||
{%- if ns_state.thinking %}
|
||||
{%- set _terse_lead = 'Answer directly, after thinking. Lead with the answer, then only what it needs to be correct and usable.' %}
|
||||
{%- else %}
|
||||
{%- set _terse_lead = 'Answer directly and concisely. Give the answer with only what it needs to be correct and usable.' %}
|
||||
{%- endif %}
|
||||
{%- set _terse_core %}
|
||||
Never: open with preamble or pleasantries; restate the question; add filler transitions; hedge with niceties; or repeat a point you've already made.
|
||||
Always: keep essential steps, caveats, uncertainties, and specifics — never drop correctness or a needed warning for brevity. Keep the final answer lean. Use the least structure that conveys it (plain prose when short; lists or code only when they earn their place). If genuinely uncertain, say so and explain why — never omit uncertainty for the sake of brevity.
|
||||
If a user request is genuinely ambiguous, ask a sharp question, don't guess.
|
||||
{%- endset %}
|
||||
{%- set _terse = _terse_lead ~ '\n' ~ (_terse_core | trim) %}
|
||||
{#- `terse` chat-template kwarg: default true, so behaviour is unchanged unless a caller opts out.
|
||||
Pass {"terse": false} via chat_template_kwargs to serve the model with only its own system
|
||||
prompt -- useful for A/B-ing the terseness effect, or when a downstream prompt conflicts. #}
|
||||
{%- set _terse_on = terse if terse is defined else true %}
|
||||
{%- if _terse_on %}
|
||||
{%- if not _sc %}
|
||||
{%- set _sc = _terse | trim %}
|
||||
{%- else %}
|
||||
{%- set _sc = (_sc | trim) ~ '\n\n' ~ (_terse | trim) %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if _has_tools %}
|
||||
{{- '<|im_start|>system\n' }}
|
||||
{%- if reasoning_instructions %}
|
||||
{{- reasoning_instructions + '\n\n' }}
|
||||
{%- endif %}
|
||||
{{- '# Tools\n\nYou have access to the following functions:\n\n<tools>' }}
|
||||
{%- for tool in tools %}
|
||||
{{- '\n' }}
|
||||
{{- tool | tojson }}
|
||||
{%- endfor %}
|
||||
{{- '\n</tools>' }}
|
||||
{%- if _tool_format == 'json' %}
|
||||
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n{"name": "example_function_name", "arguments": {"example_parameter_1": "value_1", "example_parameter_2": "This is the value for the second parameter"}}\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: a single JSON object with "name" and "arguments" keys inside <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> tag MUST be at the very beginning of a new line, with NO spaces or indentation before it.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
|
||||
{%- else %}
|
||||
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> and <function> tags MUST be at the very beginning of a new line, with NO spaces or indentation before them.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
|
||||
{%- endif %}
|
||||
{%- if _sc %}
|
||||
{{- '\n\n' + _sc }}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- else %}
|
||||
{%- if _sc %}
|
||||
{{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + _sc + '<|im_end|>\n' }}
|
||||
{%- elif reasoning_instructions %}
|
||||
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set _last_idx = _msgs | length - 1 %}
|
||||
{%- set ns = namespace(multi_step_tool=true, last_query_index=_last_idx) %}
|
||||
{%- for message in _msgs[::-1] %}
|
||||
{%- set index = (_msgs | length - 1) - loop.index0 %}
|
||||
{%- if ns.multi_step_tool and message.role == 'user' %}
|
||||
{%- set _rc = render_content(message.content, false) | trim %}
|
||||
{%- if not (_rc.startswith('<tool_response>') and _rc.endswith('</tool_response>')) %}
|
||||
{%- set ns.multi_step_tool = false %}
|
||||
{%- set ns.last_query_index = index %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if ns.multi_step_tool %}
|
||||
{%- if _last_idx > 50 %}
|
||||
{%- set ns.last_query_index = _last_idx %}
|
||||
{%- else %}
|
||||
{%- set ns.last_query_index = 0 %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set ns2 = namespace(prev_role='', consecutive_failures=0) %}
|
||||
{%- for message in _msgs %}
|
||||
{%- set is_system = (message.role == "system" or message.role == "developer") %}
|
||||
{%- set content = render_content(message.content, true, is_system) | trim %}
|
||||
{%- if is_system or message.role == 'user' %}
|
||||
{%- if '<|think_off|>' in content %}{%- set content = content.split('<|think_off|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_on|>' in content %}{%- set content = content.split('<|think_on|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_xhigh|>' in content %}{%- set content = content.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_high|>' in content %}{%- set content = content.split('<|think_high|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_ultracode|>' in content %}{%- set content = content.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_extreme|>' in content %}{%- set content = content.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_max|>' in content %}{%- set content = content.split('<|think_max|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_medium|>' in content %}{%- set content = content.split('<|think_medium|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_low|>' in content %}{%- set content = content.split('<|think_low|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_minimal|>' in content %}{%- set content = content.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if is_system %}
|
||||
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
|
||||
{%- elif message.role == 'user' %}
|
||||
{%- set ns2.consecutive_failures = 0 %}
|
||||
{{- '<|im_start|>user\n' + content + '<|im_end|>\n' }}
|
||||
{%- elif message.role == 'assistant' %}
|
||||
{%- set reasoning_content = '' %}
|
||||
{%- set _explicit_reasoning = '' %}
|
||||
{%- if message.reasoning_content is defined and message.reasoning_content is not none %}
|
||||
{%- if message.reasoning_content is string %}
|
||||
{%- set _explicit_reasoning = message.reasoning_content %}
|
||||
{%- else %}
|
||||
{%- set _explicit_reasoning = message.reasoning_content | string %}
|
||||
{%- endif %}
|
||||
{%- elif message.thinking is defined and message.thinking is not none %}
|
||||
{%- if message.thinking is string %}
|
||||
{%- set _explicit_reasoning = message.thinking %}
|
||||
{%- else %}
|
||||
{%- set _explicit_reasoning = message.thinking | string %}
|
||||
{%- endif %}
|
||||
{%- elif message.reasoning is defined and message.reasoning is not none %}
|
||||
{%- if message.reasoning is string %}
|
||||
{%- set _explicit_reasoning = message.reasoning %}
|
||||
{%- else %}
|
||||
{%- set _explicit_reasoning = message.reasoning | string %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if _explicit_reasoning %}
|
||||
{%- set _lead_end = '' %}
|
||||
{%- if content.startswith('<think>') and '</think>' in content %}
|
||||
{%- set _lead_end = '</think>' %}
|
||||
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
|
||||
{%- set _lead_end = '</thinking>' %}
|
||||
{%- elif content.startswith('</think>') %}
|
||||
{%- set _lead_end = '</think>' %}
|
||||
{%- elif content.startswith('</thinking>') %}
|
||||
{%- set _lead_end = '</thinking>' %}
|
||||
{%- endif %}
|
||||
{%- if _lead_end %}
|
||||
{%- set content = content.split(_lead_end)[-1].lstrip('\n') %}
|
||||
{%- endif %}
|
||||
{%- set reasoning_content = _explicit_reasoning %}
|
||||
{%- else %}
|
||||
{%- set _think_end = '' %}
|
||||
{%- if content.startswith('</think>') %}
|
||||
{%- set _think_end = '</think>' %}
|
||||
{%- elif content.startswith('</thinking>') %}
|
||||
{%- set _think_end = '</thinking>' %}
|
||||
{%- elif '\n</think>' in content %}
|
||||
{%- set _think_end = '\n</think>' %}
|
||||
{%- elif '\n</thinking>' in content %}
|
||||
{%- set _think_end = '\n</thinking>' %}
|
||||
{%- elif '\n</ think>' in content %}
|
||||
{%- set _think_end = '\n</ think>' %}
|
||||
{%- elif '\n</think >' in content %}
|
||||
{%- set _think_end = '\n</think >' %}
|
||||
{%- elif content.startswith('<think>') and '</think>' in content %}
|
||||
{%- set _think_end = '</think>' %}
|
||||
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
|
||||
{%- set _think_end = '</thinking>' %}
|
||||
{%- endif %}
|
||||
{%- if _think_end %}
|
||||
{%- if 'thinking' in _think_end %}
|
||||
{%- set _think_start = '<thinking>' %}
|
||||
{%- else %}
|
||||
{%- set _think_start = '<think>' %}
|
||||
{%- endif %}
|
||||
{%- set reasoning_content = content.split(_think_end)[0].rstrip('\n') %}
|
||||
{%- if _think_start in reasoning_content %}
|
||||
{%- set reasoning_content = reasoning_content.split(_think_start)[-1].lstrip('\n') %}
|
||||
{%- endif %}
|
||||
{%- set content = content.split(_think_end)[-1].lstrip('\n') %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set reasoning_content = reasoning_content | trim %}
|
||||
{%- if (_preserve_thinking or loop.index0 > ns.last_query_index) %}
|
||||
{{- '<|im_start|>assistant\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
|
||||
{%- else %}
|
||||
{{- '<|im_start|>assistant\n' + content }}
|
||||
{%- endif %}
|
||||
{%- if message.tool_calls is defined and message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
|
||||
{%- for tool_call in message.tool_calls %}
|
||||
{%- if tool_call.function is defined and tool_call.function is not none %}
|
||||
{%- set tc = tool_call.function %}
|
||||
{%- else %}
|
||||
{%- set tc = tool_call %}
|
||||
{%- endif %}
|
||||
{%- set tc_name = tc.name if (tc.name is defined and tc.name is not none) else '' %}
|
||||
{%- if _tool_format == 'json' %}
|
||||
{%- if loop.first %}
|
||||
{%- if content | trim %}
|
||||
{{- '\n\n' }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '\n' }}
|
||||
{%- endif %}
|
||||
{%- set _args = '{}' %}
|
||||
{%- if tc.arguments is defined and tc.arguments is not none %}
|
||||
{%- if tc.arguments is mapping %}
|
||||
{%- set _args = tc.arguments | tojson %}
|
||||
{%- elif tc.arguments is string %}
|
||||
{%- if tc.arguments %}
|
||||
{%- set _args = tc.arguments %}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{%- set _args = tc.arguments | tojson %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{{- '<tool_call>\n{"name": ' }}{{- tc_name | tojson }}{{- ', "arguments": ' }}{{- _args }}{{- '}\n</tool_call>' }}
|
||||
{%- else %}
|
||||
{%- if loop.first %}
|
||||
{%- if content | trim %}
|
||||
{{- '\n\n<tool_call>\n<function=' + tc_name + '>\n' }}
|
||||
{%- else %}
|
||||
{{- '<tool_call>\n<function=' + tc_name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '\n<tool_call>\n<function=' + tc_name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- if tc.arguments is defined and tc.arguments is not none %}
|
||||
{%- if tc.arguments is mapping %}
|
||||
{%- for args_name, args_value in tc.arguments.items() %}
|
||||
{{- '<parameter=' + args_name + '>\n' }}
|
||||
{%- if args_value is string %}
|
||||
{%- set _av = args_value %}
|
||||
{%- else %}
|
||||
{%- set _av = args_value | tojson %}
|
||||
{%- endif %}
|
||||
{%- if max_tool_arg_chars > 0 and _av | length > max_tool_arg_chars %}
|
||||
{{- _av[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_av | length | string) ~ ' chars]' }}
|
||||
{%- else %}
|
||||
{{- _av }}
|
||||
{%- endif %}
|
||||
{{- '\n</parameter>\n' }}
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
{%- if tc.arguments is string %}
|
||||
{%- set _raw_args = tc.arguments %}
|
||||
{%- else %}
|
||||
{%- set _raw_args = tc.arguments | tojson %}
|
||||
{%- endif %}
|
||||
{%- if _raw_args %}
|
||||
{%- if max_tool_arg_chars > 0 and _raw_args | length > max_tool_arg_chars %}
|
||||
{{- _raw_args[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_raw_args | length | string) ~ ' chars]' }}
|
||||
{%- else %}
|
||||
{{- _raw_args }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{{- '</function>\n</tool_call>' }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- elif message.role == 'tool' %}
|
||||
{%- set _content_lower = content | lower %}
|
||||
{%- set _content_head = _content_lower[:120] %}
|
||||
{%- set _is_code_or_grep = ('throw new ' in _content_lower or 'throw error' in _content_lower or 'console.error' in _content_lower or 'logger.error' in _content_lower or 'logging.error' in _content_lower or 'import ' in _content_head or 'def ' in _content_head or 'function ' in _content_head) %}
|
||||
{%- set _exit_code_zero = ('exit code: 0' in _content_head or 'process exited with code 0' in _content_head) %}
|
||||
{%- set _error_field_ok = ('"error": null' in _content_head or '"error":null' in _content_head or '"error": false' in _content_head or '"error":false' in _content_head or '"error": ""' in _content_head or '"error":""' in _content_head) %}
|
||||
{%- set _strong_error = (('"error":' in _content_head and not _error_field_ok) or '"status": "error"' in _content_head or '"status":"error"' in _content_head or 'traceback (most recent call last):' in _content_head or 'command not found' in _content_head or 'invalid syntax' in _content_head or 'fatal:' in _content_head or (('exit code: ' in _content_head or 'process exited with code' in _content_head) and not _exit_code_zero) or _content_head.startswith('exception:') or _content_head.startswith('failed to ')) %}
|
||||
{%- set _weak_error = ('error:' in _content_head or 'err!' in _content_head) %}
|
||||
{%- set _weak_suppressed = ('$ ' in _content_head or 'took ' in _content_head or content | length >= 600) %}
|
||||
{%- if not _is_code_or_grep and (_strong_error or (_weak_error and not _weak_suppressed)) %}
|
||||
{%- set ns2.consecutive_failures = ns2.consecutive_failures + 1 %}
|
||||
{%- else %}
|
||||
{%- set ns2.consecutive_failures = 0 %}
|
||||
{%- endif %}
|
||||
{%- if ns2.prev_role != 'tool' %}
|
||||
{{- '<|im_start|>user' }}
|
||||
{%- endif %}
|
||||
{%- if _tool_format != 'json' and max_tool_response_chars > 0 and content | length > max_tool_response_chars %}
|
||||
{%- set content = content[:max_tool_response_chars] + '\n[TRUNCATED - original length ' ~ (content | length | string) ~ ' chars]' %}
|
||||
{%- endif %}
|
||||
{{- '\n<tool_response>\n' + content }}
|
||||
{%- if ns2.consecutive_failures >= 2 %}
|
||||
{{- '\n\n⚠️ SYSTEM WARNING: ' ~ ns2.consecutive_failures ~ ' consecutive tool errors detected. Your previous approach is incorrect. You MUST use a fundamentally different approach or corrected arguments.' }}
|
||||
{%- elif ns2.consecutive_failures == 1 %}
|
||||
{{- '\n\n⚠️ SYSTEM WARNING: The previous tool call returned an error. Diagnose the failure and retry with completely corrected arguments.' }}
|
||||
{%- endif %}
|
||||
{{- '\n</tool_response>' }}
|
||||
{%- if loop.last %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- else %}
|
||||
{%- set _next_role = _msgs[loop.index0 + 1].role %}
|
||||
{%- if _next_role != 'tool' %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '<|im_start|>user\n[' + message.role + ']: ' + content + '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- set ns2.prev_role = message.role %}
|
||||
{%- endfor %}
|
||||
{%- if add_generation_prompt %}
|
||||
{{- '<|im_start|>assistant\n' }}
|
||||
{%- if not ns_state.thinking %}
|
||||
{{- '<think>\n\n</think>\n\n' }}
|
||||
{%- else %}
|
||||
{{- '<think>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download the kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT payload for
|
||||
# frank-medium from HuggingFace into
|
||||
# /home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT.
|
||||
#
|
||||
# Run on Dankserver (the box that serves it). The repo is public, so the
|
||||
# download works anonymously; a token in ~/.cache/huggingface/token is used
|
||||
# when present, which keeps the ~28 GB main file clear of the anonymous
|
||||
# rate limit at full speed.
|
||||
#
|
||||
# bash scripts/download-q8agent.sh
|
||||
# bash scripts/download-q8agent.sh --verify-only
|
||||
#
|
||||
# The script is resumable (curl -C -) and verifies every artifact's size
|
||||
# afterwards, so a truncated file fails loudly instead of load-crashing.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF"
|
||||
DIR="/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT"
|
||||
EXPECTED=(
|
||||
"Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf:28214425952"
|
||||
"mmproj-Qwen3.8-27B-BF16.gguf:931145888"
|
||||
"mtp-Qwen3.8-27B-Q4_0.gguf:1680271648"
|
||||
)
|
||||
# The model card's MTP prompt-cache fix ships in the same repo as a patch;
|
||||
# fetched for reference only. This fork's main already carries the
|
||||
# speculative boundary in the context checkpoint, so nothing applies it.
|
||||
EXTRA=(
|
||||
"patches/mtp-prompt-cache-fix.patch"
|
||||
)
|
||||
|
||||
cd -- "$(dirname -- "$0")/.."
|
||||
|
||||
main() {
|
||||
local mode="download"
|
||||
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
|
||||
mkdir -p "$DIR"
|
||||
cd "$DIR"
|
||||
|
||||
local token="" auth=()
|
||||
if [[ -r "$HOME/.cache/huggingface/token" ]]; then
|
||||
token="$(cat "$HOME/.cache/huggingface/token")"
|
||||
auth=(-H "Authorization: Bearer $token")
|
||||
else
|
||||
echo "no HF token at $HOME/.cache/huggingface/token; downloading anonymously"
|
||||
fi
|
||||
|
||||
dl() {
|
||||
local f="$1"
|
||||
mkdir -p "$(dirname "$f")"
|
||||
curl -fsSL -C - -o "$f" --retry 10 --retry-delay 5 --retry-max-time 10800 \
|
||||
"${auth[@]}" \
|
||||
"https://huggingface.co/$REPO/resolve/main/$f"
|
||||
echo " done: $f ($(du -h "$f" | cut -f1))"
|
||||
}
|
||||
|
||||
if [[ "$mode" == "download" ]]; then
|
||||
echo "downloading $REPO into $DIR"
|
||||
for entry in "${EXPECTED[@]}"; do
|
||||
dl "${entry%%:*}"
|
||||
done
|
||||
for f in "${EXTRA[@]}"; do
|
||||
dl "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "verifying sizes"
|
||||
local ok=1 f want act
|
||||
for entry in "${EXPECTED[@]}"; do
|
||||
f="${entry%%:*}"; want="${entry##*:}"
|
||||
act="$(stat -c%s "$f" 2>/dev/null || echo 0)"
|
||||
if [[ "$act" -eq "$want" ]]; then
|
||||
echo " OK $f ($act bytes)"
|
||||
else
|
||||
echo " BAD $f ($act bytes, want $want)" >&2
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [[ -z "$token" ]]; then
|
||||
echo "note: no token was used; sizes above are the integrity check"
|
||||
fi
|
||||
[[ "$ok" -eq 1 ]] || exit 1
|
||||
echo "all files present and sized correctly"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,17 +0,0 @@
|
||||
# Tunables for frank-default-npu (FastFlowLM NPU endpoint, Dankclaw)
|
||||
# Runtime: FLM 1.0.1 on the XDNA2 NPU (50 TOPS). Model: Gemma4-E4B-IT
|
||||
# (8B, Q4_1, vision + audio + reasoning). The NPU allocates from the
|
||||
# unified pool shared with frank-default-large (the CIRU llama.cpp
|
||||
# service on the iGPU). See serverAppConfigs-docs/FastFlowLM NPU Inference.md.
|
||||
#
|
||||
# 2026-08-20: gemma4-it:e4b deployed. Footprint ~9.1 GB. 64k context.
|
||||
|
||||
FLM_MODEL=gemma4-it:e4b
|
||||
FLM_HOST=0.0.0.0
|
||||
FLM_PORT=52626
|
||||
FLM_PMODE=performance
|
||||
FLM_ASR=0
|
||||
# 64K context (native max for gemma4-it is 32768, but NPU supports up to 256k).
|
||||
# The model's max_prefill_len is 4096, so prompts longer than that get
|
||||
# truncated at prefill time.
|
||||
FLM_CTX_LEN=65536
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=FastFlowLM NPU inference (frank-default-npu) on Dankclaw
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FLM_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-npu.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-default-npu.sh
|
||||
LimitMEMLOCK=infinity
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,105 +0,0 @@
|
||||
# Tunables for frank-dragoon (mradermacher i1-Q4_K_M of
|
||||
# orcarouter/Qwen3.8-Flash-Next-Uncensored on ik_llama.cpp, Dankclaw,
|
||||
# RTX 3090 CUDA + Strix Halo CPU path, since 2026-09-15).
|
||||
#
|
||||
# Runtime: ik_llama.cpp master d85a1d7 (2026-09-15) with full qwen4exp
|
||||
# support (upstream PRs #2365/#2369/#2373 merged). Build recipe (CUDA for
|
||||
# the 3090 at sm_86, Zen5 AVX-512 IQK kernels for the CPU expert path):
|
||||
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
|
||||
# -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 \
|
||||
# -DGGML_AVX512=ON -DGGML_AVX512_VBMI=ON -DGGML_AVX512_VNNI=ON \
|
||||
# -DGGML_AVX512_BF16=ON -DLLAMA_CURL=OFF
|
||||
# The startup banner must print HAVE_FANCY_SIMD is defined — without those
|
||||
# macros the quantized GEMM silently falls back to AVX2 (no warning).
|
||||
#
|
||||
# Model: mradermacher imatrix quant of the uncensored Flash-Next (qwen4exp
|
||||
# MoE: 512 experts / 10 used, 48 layers = 12 attention + 36 GDN/ssm, QSA
|
||||
# indexer, hyper-connections, PLE n-gram table ~320M rows). 111 GiB shipped
|
||||
# as 3 parts, concatenated on disk; each part's sha256 was verified against
|
||||
# its HF LFS oid at download time.
|
||||
#
|
||||
# Placement (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE):
|
||||
# - -ot "exps=CPU": ALL 512-expert tensors (121B elems, ~84 GB) live in
|
||||
# the Strix Halo's LPDDR5X via the plain CPU backend buffer — mmap-backed
|
||||
# page cache, ik's fastest known configuration for this shape (the
|
||||
# author's 2x3090 + Threadripper reference box measures ~50 t/s on the
|
||||
# same model family). The shared experts (shexp) keep their GPU-side
|
||||
# default: they run on every token.
|
||||
# - -ot "per_layer_token_embd=CPU": the ~32 GB PLE n-gram table follows —
|
||||
# only a tiny slice is touched per token, so it streams from page cache.
|
||||
# - NEVER use --cpu-moe on this host: it routes the CPU-overridden
|
||||
# weights through the CUDA_Host PINNED buffer (measured 2026-09-15:
|
||||
# 108.12 GiB page-locked, evicted every co-tenant into swap and starved
|
||||
# sshd into unreachability until a power cycle). -ot ...=CPU is the
|
||||
# mmap-backed path with identical DDR5 bandwidth for resident pages,
|
||||
# and the kernel degrades gracefully under pressure (page cache evict
|
||||
# + re-read) instead of stranding anyone.
|
||||
# - GGML_CUDA_NO_PINNED_WEIGHTS=1 (src/llama-load-tensors.cpp) keeps the
|
||||
# CPU-overridden weights mmapped and leaves only the small staging
|
||||
# buffers pinned. (GGML_CUDA_NO_PINNED would also unpin staging —
|
||||
# unnecessary here and slower for prefill transfers.)
|
||||
#
|
||||
# Sampling: greedy-by-default to preserve the halogen-era client contract
|
||||
# (a request without temperature decodes greedy; temp 0.0 = argmax).
|
||||
# Clients that send their own temperature get their own values.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (see
|
||||
# the 2026-08-13 HIP incident in AGENTS.md). llama.cpp reads --host and
|
||||
# --temp as CLI flags, never as environment variables.
|
||||
|
||||
# ik_llama.cpp runtime binary (master d85a1d7 build).
|
||||
LLAMA_BIN=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server
|
||||
|
||||
# Model package from
|
||||
# https://huggingface.co/mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUF
|
||||
# (parts concatenated). The mmproj is the matching vision tower from the
|
||||
# static repo mradermacher/Qwen3.8-Flash-Next-Uncensored-GGUF — ik_llama.cpp
|
||||
# links examples/mtmd, so image input survives the runtime swap.
|
||||
MODEL_DIR=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M
|
||||
MODEL_FILE=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf
|
||||
MMPROJ_PATH=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf
|
||||
|
||||
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The
|
||||
# server emits reasoning in DeepSeek format so clients receive
|
||||
# reasoning_content separately instead of literal think tags in content.
|
||||
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
|
||||
|
||||
# Alias + network.
|
||||
ALIAS=frank-dragoon
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Serving: single 262144-token slot (the model's full context window),
|
||||
# batch 4096 / ubatch 512, 16+16 threads (the Halo CPU has 16 cores, no
|
||||
# SMT), f16 KV. The KV cache scales with the 12 full-attention layers only
|
||||
# (~6 GB f16 at 262k); the GDN/ssm layers keep constant-size state. If
|
||||
# VRAM headroom is needed, --cache-type-k/v q8_0 halves the KV footprint.
|
||||
CTX_SIZE=262144
|
||||
PARALLEL=1
|
||||
BATCH_SIZE=4096
|
||||
UBATCH_SIZE=512
|
||||
NUM_THREADS=16
|
||||
THREAD_BATCH=16
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
|
||||
# GPU/MoE placement: everything on the 3090 except the experts and the PLE
|
||||
# table, which both live in host RAM via the CPU backend buffer (see the
|
||||
# placement notes above).
|
||||
NGL=999
|
||||
# ik_llama.cpp CUDA host-memory policy: CPU-overridden weights stay mmapped;
|
||||
# only the small staging buffers are pinned (see the placement notes above).
|
||||
GGML_CUDA_NO_PINNED_WEIGHTS=1
|
||||
|
||||
# Prompt cache (RAM budget in MiB) and slot state dir.
|
||||
CACHE_RAM=8192
|
||||
SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state
|
||||
|
||||
# Greedy-by-default (halogen contract). See sampling note above.
|
||||
TEMPERATURE=0.0
|
||||
|
||||
# Reasoning on: DeepSeek format projects thinking into the API's
|
||||
# reasoning_content field instead of literal think tags in content;
|
||||
# unlimited reasoning budget (the model decides).
|
||||
REASONING_FORMAT=deepseek
|
||||
REASONING_BUDGET=-1
|
||||
@@ -1,21 +0,0 @@
|
||||
[Unit]
|
||||
Description=ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M 177B MoE (dankclaw, frank-dragoon)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
# Staging buffers for the CUDA transfers are page-locked; the weights
|
||||
# themselves stay mmapped (GGML_CUDA_NO_PINNED_WEIGHTS=1), but the staging
|
||||
# budget still needs room. Parity with the halogen quadlet's
|
||||
# Ulimit=memlock=-1:-1.
|
||||
LimitMEMLOCK=infinity
|
||||
Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,138 +0,0 @@
|
||||
# Tunables for frank-medium (kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF,
|
||||
# charlie12345/ROCmFPX llama.cpp, Dankserver, 2x AMD Radeon AI PRO R9700).
|
||||
#
|
||||
# Hardware (2026-09-15): the RTX 3090 left Dankserver and two Radeon AI PRO
|
||||
# R9700 cards (Navi 48, gfx1201, RDNA4, 32,624 MiB each, PCIe 5.0 x16) took
|
||||
# its place. The Raphael iGPU (gfx1036) is still enumerated by HIP as the
|
||||
# third device. There is no XGMI link between the two cards and they sit in
|
||||
# different IOMMU groups, so any cross-GPU traffic goes over PCIe.
|
||||
#
|
||||
# Runtime: charlie12345/ROCmFPX (llama.cpp fork, ROCmFPX quant family; the
|
||||
# Q8_0_ROCMFPX_AGENT tensor type exists nowhere else - stock llama.cpp cannot
|
||||
# load this GGUF). Built for gfx1201 with the fork's self-contained ROCm
|
||||
# 7.14 toolchain so the host's stale /opt/rocm-6.3.3 install is never used:
|
||||
#
|
||||
# cd /home/dank/GitPackages/charlie-ROCmFPX
|
||||
# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh
|
||||
#
|
||||
# gfx1201 must be the build target: a gfx1200 binary links, loads the model
|
||||
# and then segfaults on this card (fork's docs/BUILD-AMD-ARCHITECTURES.md).
|
||||
#
|
||||
# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT - Q8_0_ROCMFPX_AGENT
|
||||
# (ftype 115), 26.28 GiB, 8.39 bpw, arch qwen35 (MoE), quantized from a
|
||||
# 51.3 GiB BF16 source. The AGENT routing keeps 12 more attention-output
|
||||
# projections at full Q8_0 than the plain 8-bit build, which buys a higher
|
||||
# MTP draft acceptance (0.953 vs 0.911) and that is the whole point of this
|
||||
# checkpoint. Model card documents 131,072 context.
|
||||
#
|
||||
# Parallelism (--split-mode layer, operator call 2026-09-15 "pipeline
|
||||
# parallelism"): layers are distributed across the two cards in order and
|
||||
# activations hop PCIe at the boundary. Measured with llama-bench
|
||||
# (pp4096 / tg128 / same at depth 32768, f16 KV, fa on, 3 reps):
|
||||
#
|
||||
# single R9700 1160.9 / 19.42 (fits: 26.27 GiB weights, ~5 GiB left)
|
||||
# layer 2x 1991.2 / 18.76 859.1 / 17.64
|
||||
# tensor 2x 271.8 / 26.26 228.6 / 25.09
|
||||
#
|
||||
# Tensor parallel wins decode (+40 %) but collapses prefill 7x: this build
|
||||
# has no RCCL (llama.cpp logs "AllReduce init failed; falling back to
|
||||
# meta-backend butterfly"), so every layer pays a slow cross-card reduction.
|
||||
# Agentic traffic is prefill-heavy, so layer split wins the workload. Revisit
|
||||
# if the fork ships a working RCCL tensor path.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
|
||||
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
|
||||
# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as
|
||||
# env vars.
|
||||
|
||||
# charlie12345/ROCmFPX llama-server (self-contained ROCm 7.14 build, RPATH
|
||||
# points at the bundled runtime libs; no LD_LIBRARY_PATH needed).
|
||||
LLAMA_BIN=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server
|
||||
|
||||
# Model package, fetched with scripts/download-q8agent.sh:
|
||||
# Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf 26.28 GiB main weights
|
||||
# mtp-Qwen3.8-27B-Q4_0.gguf 1.68 GiB MTP draft head
|
||||
# mmproj-Qwen3.8-27B-BF16.gguf 0.93 GiB vision tower (unused)
|
||||
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT
|
||||
MODEL_FILE=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf
|
||||
DRAFT_MODEL_FILE=mtp-Qwen3.8-27B-Q4_0.gguf
|
||||
|
||||
# Alias + network. The port is the fleet's dankserver endpoint; the alias is
|
||||
# what clients ask for, and it replaces the retired `frank-small`.
|
||||
ALIAS=frank-medium
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Device placement. HIP_VISIBLE_DEVICES hides the iGPU (device 2) so nothing
|
||||
# can drift onto shared system RAM; DEVICES is what llama.cpp splits across.
|
||||
HIP_VISIBLE_DEVICES=0,1
|
||||
DEVICES=ROCm0,ROCm1
|
||||
SPLIT_MODE=layer
|
||||
|
||||
# TENSOR_SPLIT empty = layers distributed by free VRAM (equal here).
|
||||
TENSOR_SPLIT=
|
||||
|
||||
NGL=999
|
||||
# Auto-fit has no business resizing a context we set explicitly.
|
||||
FIT=off
|
||||
FLASH_ATTN=on
|
||||
|
||||
# 131,072 tokens, one slot (the model card's documented window; the GGUF
|
||||
# reports n_ctx_train 262144, untested by the model author). Layer split
|
||||
# keeps each layer's KV on the card that owns the layer, so the window is
|
||||
# bounded by per-card free memory: ~5.6 GiB of KV per card at 131072 f16.
|
||||
CTX_SIZE=131072
|
||||
PARALLEL=1
|
||||
|
||||
# 26.28 GiB of weights + MTP draft + KV cache leaves ~15 GiB per card at this
|
||||
# context, so the batch shape is chosen for prefill throughput, not memory.
|
||||
BATCH_SIZE=2048
|
||||
UBATCH_SIZE=512
|
||||
NUM_THREADS=8
|
||||
THREAD_BATCH=8
|
||||
|
||||
# f16 K/V: an agent checkpoint is the wrong place to spend KV precision.
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
CTX_CHECKPOINTS=8
|
||||
CACHE_RAM=8192
|
||||
|
||||
# Sampling: the checkpoint's instruct table.
|
||||
TEMPERATURE=0.7
|
||||
TOP_P=0.8
|
||||
TOP_K=20
|
||||
MIN_P=0.0
|
||||
PRESENCE_PENALTY=1.5
|
||||
|
||||
# Reasoning on; DeepSeek format projects the trace into reasoning_content
|
||||
# instead of literal thinking tags in content.
|
||||
REASONING=on
|
||||
REASONING_FORMAT=deepseek
|
||||
REASONING_BUDGET=-1
|
||||
|
||||
# Chat template: empty = the template embedded in the GGUF. Point this at
|
||||
# /home/dank/serverAppConfigs/scripts/chat-template.jinja to force the repo's
|
||||
# Sharp agent template instead.
|
||||
CHAT_TEMPLATE_FILE=
|
||||
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}'
|
||||
|
||||
# MTP speculative decoding. n_max 6 / p_min 0.6 is the fork's measured
|
||||
# sweet spot for a MTP head with high acceptance; p_min 0.0 drafts
|
||||
# low-probability tokens that get rejected and makes MTP a net loss.
|
||||
SPEC_TYPE=draft-mtp
|
||||
SPEC_DRAFT_N_MAX=6
|
||||
SPEC_DRAFT_N_MIN=0
|
||||
SPEC_DRAFT_P_MIN=0.6
|
||||
SPEC_DRAFT_P_SPLIT=0.10
|
||||
SPEC_DRAFT_NGL=999
|
||||
SPEC_DRAFT_TYPE_K=f16
|
||||
SPEC_DRAFT_TYPE_V=f16
|
||||
SPEC_DRAFT_POLL=1
|
||||
SPEC_DRAFT_POLL_BATCH=1
|
||||
# The draft head rides the card that holds the target model's LAST layers:
|
||||
# the MTP head consumes the final hidden state, so pinning it to ROCm1 keeps
|
||||
# the per-token hop off the wire. (Under the abandoned tensor split the
|
||||
# draft must also be pinned to exactly one device or the meta-device KV
|
||||
# allocation aborts: GGML_ASSERT(buffer) in ggml-backend.cpp, measured
|
||||
# 2026-09-15.)
|
||||
SPEC_DRAFT_DEVICE=ROCm1
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=charlie12345/ROCmFPX llama.cpp serving kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT qwen35 MoE (dankserver, frank-medium, 2x Radeon AI PRO R9700)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve the FastFlowLM (FLM) NPU endpoint — frank-default-npu.
|
||||
#
|
||||
# Runs flm serve with the model tag in frank-default-npu.env. The NPU
|
||||
# (XDNA2, 50 TOPS) is a single-lane device: generations serialize in FIFO
|
||||
# order, so this is a background/triage endpoint. The NPU shares the ~30 GiB
|
||||
# unified pool with frank-default-large (the CIRU llama.cpp service on the
|
||||
# iGPU); see serverAppConfigs-docs/FastFlowLM NPU Inference.md for the memory budget.
|
||||
#
|
||||
# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FLM_NPU_ENV_FILE:-$SCRIPT_DIR/frank-default-npu.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${FLM_BIN:=/usr/bin/flm}"
|
||||
: "${FLM_MODEL:=gemma4-it:e4b}"
|
||||
: "${FLM_HOST:=0.0.0.0}"
|
||||
: "${FLM_PORT:=52626}"
|
||||
: "${FLM_PMODE:=performance}"
|
||||
: "${FLM_ASR:=0}"
|
||||
: "${FLM_CTX_LEN:=-1}"
|
||||
|
||||
if [[ ! -x "$FLM_BIN" ]]; then
|
||||
echo "missing flm binary: $FLM_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- optional flags ----------------------------------------------------------
|
||||
OPT_ARGS=()
|
||||
if [[ "${FLM_ASR}" != "0" ]]; then
|
||||
OPT_ARGS+=(-a "${FLM_ASR}")
|
||||
fi
|
||||
if [[ "${FLM_CTX_LEN}" != "-1" ]]; then
|
||||
OPT_ARGS+=(-c "${FLM_CTX_LEN}")
|
||||
fi
|
||||
|
||||
exec "$FLM_BIN" serve "$FLM_MODEL" \
|
||||
--host "$FLM_HOST" \
|
||||
-p "$FLM_PORT" \
|
||||
--pmode "$FLM_PMODE" \
|
||||
"${OPT_ARGS[@]}"
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve mradermacher/Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M as frank-dragoon
|
||||
# via ik_llama.cpp on Dankclaw.
|
||||
#
|
||||
# Topology (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE):
|
||||
# the RTX 3090 (CUDA, sm_86) takes the dense/attention/output weights and
|
||||
# the KV cache (-ngl 999); ALL MoE expert tensors and the ~32 GB PLE
|
||||
# n-gram table stay in the Strix Halo's LPDDR5X via the plain CPU backend
|
||||
# buffer (-ot ...=CPU — mmap-backed page cache; only a tiny slice of PLE is
|
||||
# touched per token). NEVER use --cpu-moe on this host: it routes the
|
||||
# CPU-overridden weights through the CUDA_Host PINNED buffer (measured
|
||||
# 108.12 GiB page-locked on 2026-09-15, which evicted every co-tenant into
|
||||
# swap and starved sshd into unreachability until a power cycle). NEVER add
|
||||
# --no-mmap — the STRIX-era lesson transfers directly (the cgroup killer
|
||||
# takes the process with nothing in the server log).
|
||||
#
|
||||
# Runtime: ik_llama.cpp master d85a1d7 built with CUDA (sm_86) + Zen5
|
||||
# AVX-512 IQK kernels; the startup banner must show HAVE_FANCY_SIMD
|
||||
# is defined. Flag set verified against this binary's --help before this
|
||||
# script was written. ik has no --cache-prompt/--cache-idle-slots (prompt
|
||||
# caching is its default behavior) and no --alias: it is a single-model
|
||||
# server and reports the GGUF's general.name at /v1/models.
|
||||
#
|
||||
# Sampling: greedy-by-default (halogen client contract) — clients that
|
||||
# send their own temperature get their own values.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M}"
|
||||
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf}"
|
||||
: "${MMPROJ_PATH:=$MODEL_DIR/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf}"
|
||||
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
: "${CTX_SIZE:=262144}"
|
||||
: "${PARALLEL:=1}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=16}"
|
||||
: "${THREAD_BATCH:=16}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${NGL:=999}"
|
||||
: "${CPU_MOE:=on}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
: "${TEMPERATURE:=0.0}"
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${REASONING_BUDGET:=-1}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}"
|
||||
# Hard guard: on this host CUDA pinned host memory strands the co-tenants
|
||||
# (measured 2026-09-15: a default-policy start tried to page-lock 108.12 GiB,
|
||||
# evicted every co-tenant into swap and starved sshd into unreachability
|
||||
# until a power cycle). GGML_CUDA_NO_PINNED_WEIGHTS=1 keeps the
|
||||
# CPU-overridden weights mmapped (src/llama-load-tensors.cpp); the env file
|
||||
# sets it. Refuse to run without it.
|
||||
if [[ "${GGML_CUDA_NO_PINNED_WEIGHTS:-}" != "1" ]]; then
|
||||
echo "frank-dragoon: refusing to start — GGML_CUDA_NO_PINNED_WEIGHTS=1 is mandatory on this host" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$SLOT_SAVE_PATH"
|
||||
|
||||
# The UVM device node is created lazily by nvidia-modprobe on the first
|
||||
# CUDA open after boot; make sure it exists before the server needs it.
|
||||
nvidia-modprobe -u -c=0 2>/dev/null || true
|
||||
|
||||
CPU_MOE_ARGS=()
|
||||
if [[ "$CPU_MOE" == "on" ]]; then
|
||||
# -ot ...=CPU (plain CPU backend buffer) is the mmap-backed path. NEVER
|
||||
# swap this for --cpu-moe: that routes the weights through the CUDA_Host
|
||||
# PINNED buffer (108.12 GiB measured) and strands the co-tenants.
|
||||
CPU_MOE_ARGS=(-ot "exps=CPU" -ot "per_layer_token_embd=CPU")
|
||||
fi
|
||||
|
||||
MMPROJ_ARGS=()
|
||||
if [[ -n "${MMPROJ_PATH:-}" && -f "$MMPROJ_PATH" ]]; then
|
||||
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
|
||||
fi
|
||||
|
||||
TEMPLATE_ARGS=()
|
||||
if [[ -n "${CHAT_TEMPLATE_FILE:-}" && -f "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
TEMPLATE_ARGS=(--chat-template-file "$CHAT_TEMPLATE_FILE")
|
||||
else
|
||||
TEMPLATE_ARGS=(--jinja)
|
||||
fi
|
||||
|
||||
exec "$LLAMA_BIN" \
|
||||
--model "$MODEL_DIR/$MODEL_FILE" \
|
||||
"${TEMPLATE_ARGS[@]}" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--gpu-layers "$NGL" \
|
||||
"${CPU_MOE_ARGS[@]}" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--slot-save-path "$SLOT_SAVE_PATH" \
|
||||
--ctx-checkpoints 16 \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget "$REASONING_BUDGET" \
|
||||
"${MMPROJ_ARGS[@]}"
|
||||
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF as frank-medium via
|
||||
# the charlie12345/ROCmFPX llama.cpp runtime on Dankserver
|
||||
# (2x AMD Radeon AI PRO R9700, Navi 48, gfx1201, 32 GB each).
|
||||
#
|
||||
# Runtime: charlie12345/ROCmFPX main (c49ebdb, 2026-08-22), built for
|
||||
# gfx1201 with the fork's self-contained ROCm toolchain:
|
||||
#
|
||||
# cd /home/dank/GitPackages/charlie-ROCmFPX
|
||||
# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh
|
||||
# -> build-rdna4-rocm714/bin/llama-server
|
||||
#
|
||||
# That script downloads the gfx120X-all TheRock ROCm 7.14 toolchain into
|
||||
# ~/.local/share/rocm-7.14.0a20260624, builds with
|
||||
# -DCMAKE_HIP_ARCHITECTURES="gfx1200;gfx1201", copies the ROCm runtime
|
||||
# (hip/hsa/rocblas/hipblaslt + their kernel libraries) next to the binary and
|
||||
# sets RPATH=$ORIGIN/../lib on it. The resulting service is self-contained:
|
||||
# no LD_LIBRARY_PATH, and the host's stale /opt/rocm-6.3.3 cannot leak in.
|
||||
# gfx1201 is NOT interchangeable with gfx1200 on this card (the fork's
|
||||
# docs/BUILD-AMD-ARCHITECTURES.md: a gfx1200 build links, loads and then
|
||||
# segfaults), and the build verifies the emitted code objects afterwards.
|
||||
#
|
||||
# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT (Q8_0_ROCMFPX_AGENT,
|
||||
# ftype 115, arch qwen35 MoE), 26.28 GiB main file, plus a separate
|
||||
# MTP draft head and an mmproj vision tower. 8.39 bpw; the AGENT routing
|
||||
# keeps 13 of the 17 attention-output projections at full Q8_0, which is
|
||||
# what makes the MTP draft land ~0.95 of its guesses. A stock llama.cpp
|
||||
# cannot load this GGUF at all - Q8_0_ROCMFPX exists only in this fork.
|
||||
#
|
||||
# Parallelism: --split-mode layer across both cards (operator call
|
||||
# 2026-09-15). Layers are distributed in order and activations hop PCIe at
|
||||
# the boundary; measured llama-bench: layer 1991/18.8 tok/s (pp4096/tg128)
|
||||
# vs tensor 272/26.3 and single-card 1161/19.4. Tensor wins decode +40 %
|
||||
# but its prefill collapses 7x on this build (no RCCL: "AllReduce init
|
||||
# failed; falling back to meta-backend butterfly"), so layer split wins the
|
||||
# prefill-heavy agentic workload.
|
||||
#
|
||||
# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE
|
||||
# overrides that path.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -----------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT}"
|
||||
: "${MODEL_FILE:=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf}"
|
||||
: "${DRAFT_MODEL_FILE:=mtp-Qwen3.8-27B-Q4_0.gguf}"
|
||||
: "${ALIAS:=frank-medium}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
|
||||
# Devices: ROCm0/ROCm1 are the two R9700s; the Raphael iGPU enumerates as
|
||||
# ROCm2 (gfx1036) and is hidden from HIP so nothing can drift onto it.
|
||||
: "${HIP_VISIBLE_DEVICES:=0,1}"
|
||||
: "${DEVICES:=ROCm0,ROCm1}"
|
||||
: "${SPLIT_MODE:=tensor}"
|
||||
: "${TENSOR_SPLIT:=}"
|
||||
: "${NGL:=999}"
|
||||
: "${FIT:=off}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
|
||||
: "${CTX_SIZE:=131072}"
|
||||
: "${PARALLEL:=1}"
|
||||
: "${BATCH_SIZE:=2048}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=8}"
|
||||
: "${THREAD_BATCH:=8}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${CTX_CHECKPOINTS:=8}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
|
||||
# Sampling: the checkpoint's instruct table.
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_P:=0.8}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${PRESENCE_PENALTY:=1.5}"
|
||||
|
||||
# Reasoning: the model is an agent checkpoint; keep the trace and project it
|
||||
# into the API's reasoning_content field rather than literal thinking tags.
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${REASONING_BUDGET:=-1}"
|
||||
|
||||
# Chat template: empty means "use the template embedded in the GGUF".
|
||||
: "${CHAT_TEMPLATE_FILE:=}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}"
|
||||
|
||||
# MTP draft head (separate GGUF, --spec-type draft-mtp). SPEC_DRAFT_DEVICE
|
||||
# must name exactly one device: under tensor mode an unsplit draft joins the
|
||||
# meta-device KV allocation and aborts while building the KV cache
|
||||
# (GGML_ASSERT(buffer) in ggml-backend.cpp, 2026-09-15).
|
||||
: "${SPEC_TYPE:=draft-mtp}"
|
||||
: "${SPEC_DRAFT_N_MAX:=6}"
|
||||
: "${SPEC_DRAFT_N_MIN:=0}"
|
||||
: "${SPEC_DRAFT_P_MIN:=0.6}"
|
||||
: "${SPEC_DRAFT_P_SPLIT:=0.10}"
|
||||
: "${SPEC_DRAFT_NGL:=999}"
|
||||
: "${SPEC_DRAFT_DEVICE:=}"
|
||||
: "${SPEC_DRAFT_TYPE_K:=f16}"
|
||||
: "${SPEC_DRAFT_TYPE_V:=f16}"
|
||||
: "${SPEC_DRAFT_POLL:=1}"
|
||||
: "${SPEC_DRAFT_POLL_BATCH:=1}"
|
||||
|
||||
# Text only: the server must not try to autoload the vision tower. Vision
|
||||
# stays on frank-dragoon (Dankclaw), which is where the fleet routes images.
|
||||
NO_MMPROJ="${NO_MMPROJ:-1}"
|
||||
|
||||
export HIP_VISIBLE_DEVICES
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
if [[ ! -x "$LLAMA_BIN" ]]; then
|
||||
echo "missing executable runtime: $LLAMA_BIN" >&2
|
||||
echo "build it with: cd $(dirname "$(dirname "$LLAMA_BIN")")/.. && env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$MODEL" ]]; then
|
||||
echo "missing or unreadable required artifact: $MODEL" >&2
|
||||
echo "fetch it with: bash $SCRIPT_DIR/download-q8agent.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- speculative decoding (MTP draft head) --------------------------------
|
||||
SPEC_ARGS=()
|
||||
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
|
||||
SPEC_ARGS=(
|
||||
--spec-type "$SPEC_TYPE"
|
||||
--spec-draft-n-max "$SPEC_DRAFT_N_MAX"
|
||||
--spec-draft-n-min "$SPEC_DRAFT_N_MIN"
|
||||
--spec-draft-p-min "$SPEC_DRAFT_P_MIN"
|
||||
--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT"
|
||||
)
|
||||
if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then
|
||||
DRAFT_MODEL="$MODEL_DIR/$DRAFT_MODEL_FILE"
|
||||
if [[ ! -r "$DRAFT_MODEL" ]]; then
|
||||
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SPEC_ARGS+=(
|
||||
--spec-draft-model "$DRAFT_MODEL"
|
||||
--spec-draft-ngl "$SPEC_DRAFT_NGL"
|
||||
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
|
||||
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
|
||||
--spec-draft-poll "$SPEC_DRAFT_POLL"
|
||||
--spec-draft-poll-batch "$SPEC_DRAFT_POLL_BATCH"
|
||||
)
|
||||
if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then
|
||||
SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- template + placement -------------------------------------------------
|
||||
TEMPLATE_ARGS=()
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE")
|
||||
TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS")
|
||||
fi
|
||||
|
||||
PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE")
|
||||
if [[ -n "$TENSOR_SPLIT" ]]; then
|
||||
PLACEMENT_ARGS+=(--tensor-split "$TENSOR_SPLIT")
|
||||
fi
|
||||
|
||||
MMPROJ_ARGS=()
|
||||
if [[ "$NO_MMPROJ" == "1" ]]; then
|
||||
MMPROJ_ARGS=(--no-mmproj)
|
||||
fi
|
||||
|
||||
exec "$LLAMA_BIN" \
|
||||
--model "$MODEL" \
|
||||
--alias "$ALIAS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--jinja \
|
||||
--gpu-layers "$NGL" \
|
||||
--fit "$FIT" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--ctx-checkpoints "$CTX_CHECKPOINTS" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--cache-prompt \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-p "$TOP_P" \
|
||||
--top-k "$TOP_K" \
|
||||
--min-p "$MIN_P" \
|
||||
--presence-penalty "$PRESENCE_PENALTY" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget "$REASONING_BUDGET" \
|
||||
"${TEMPLATE_ARGS[@]}" \
|
||||
"${PLACEMENT_ARGS[@]}" \
|
||||
"${MMPROJ_ARGS[@]}" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
@@ -21,7 +21,7 @@
|
||||
# deployed knob has since dropped to 262144.
|
||||
set -euo pipefail
|
||||
|
||||
IMG=ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a
|
||||
IMG=ghcr.io/peonist-ai/halogen-flash-server@sha256:e35133b206da3094cc4d70c78800b26235bd9124b9aeb0e029d4a7c153b9cff0
|
||||
CUR=/home/dank/models/halogen-flashnext-v2
|
||||
NEW=/home/dank/models/halogen-v2-abliterated
|
||||
CUR_CK=qwen38-flash-next-v2-swift15-abliterated.hgn
|
||||
|
||||
@@ -47,7 +47,12 @@
|
||||
set -u -o pipefail
|
||||
|
||||
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
IMAGE=${IMAGE:-stilldeadcode/radiance@sha256:d590e300234bf6d2f942a0b8d456d183153fd808f28820e6c2fa69c9389178ed}
|
||||
# THE TOOLCHAIN PIN. `:latest` on 2026-10-30 is v1.14.0 (revision 4a61d7296bc5),
|
||||
# the same image the tap producer boots: a tap must be converted by the line that
|
||||
# is current, and rad-convert's `gptq:calib=<dir>` input contract is version-stable
|
||||
# (RADGRAM2 + gram.r<rank>.<stem>.bin), so nothing else about the recipe moves with
|
||||
# it. Production stays on its own pin — this is the campaign's toolchain.
|
||||
IMAGE=${IMAGE:-stilldeadcode/radiance@sha256:69add58f7de1ed2bca2b1e20a4cc75717557df3f73569a178eca5aafe8c44093}
|
||||
CKPT=${CKPT:-/home/dank/models/Qwen3.8-Flash-Next}
|
||||
CAMPAIGN=${CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}
|
||||
RECIPE=${RECIPE:-$SELF/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe}
|
||||
|
||||
+16
-1
@@ -155,13 +155,14 @@ DANKCLAW_COMPOSE_SERVICES = set()
|
||||
# Quadlet unit now — the last compose services were the hermes trio, whose units
|
||||
# have owned their containers since 2026-09-12 and whose compose blocks were
|
||||
# dead weight that could only create a duplicate container.
|
||||
# jellyfin left the Dankserver file on 2026-10-30 for the same reason: its Quadlet
|
||||
# has owned the container since the cutover, so a compose edit must not restart it.
|
||||
|
||||
DANKSERVER_COMPOSE_SERVICES = {
|
||||
"mariadb",
|
||||
"redis",
|
||||
"romm",
|
||||
"copyparty",
|
||||
"jellyfin",
|
||||
"calibre-web",
|
||||
}
|
||||
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
|
||||
@@ -421,6 +422,11 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
"tautulli": {"tautulli"},
|
||||
}
|
||||
_add(services, *quadlet_services.get(parts[1], set()))
|
||||
if len(parts) >= 2 and parts[1] == "host":
|
||||
# dankserver/host/systemd/media-meta-view.mount is the overlay view the
|
||||
# media servers bind (scripts/media-view.sh installs it on the host),
|
||||
# so editing it restarts jellyfin and plex and nothing else.
|
||||
_add(services, "jellyfin", "plex")
|
||||
if len(parts) >= 2 and parts[1] == "observability":
|
||||
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
||||
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
||||
@@ -466,6 +472,15 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
# that service (target_services then keeps it only on the host being
|
||||
# planned). Editing the collector restarts the running exporter.
|
||||
_add(services, "gpu-exporter")
|
||||
if name == "media-view.sh":
|
||||
# The installer is what creates, mounts and reaps the view both media
|
||||
# servers read, so a change to it is a restart of that pair.
|
||||
_add(services, "jellyfin", "plex")
|
||||
if name == "jellyfin-providers.sh":
|
||||
# The deployer runs this before jellyfin restarts, so a change to
|
||||
# the installer is a change to the provider state Jellyfin loads at
|
||||
# start-up: it selects jellyfin and nothing else.
|
||||
_add(services, "jellyfin")
|
||||
if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
|
||||
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
|
||||
# files here; each one selects frank-zealot only. The ik_llama.cpp
|
||||
|
||||
@@ -1202,7 +1202,76 @@ deploy_romm_stack() {
|
||||
systemctl --user restart "$service.service"
|
||||
}
|
||||
|
||||
ensure_media_view() {
|
||||
# Both media servers read their library through the SSD-backed overlay view
|
||||
# (scripts/media-view.sh, dankserver/host/systemd/media-meta-view.mount): the
|
||||
# array below stays read-only and every sidecar Jellyfin writes lands on the
|
||||
# NVMe.
|
||||
# Raise it BEFORE either container starts — a start against a bare view
|
||||
# directory would let a scan clear the library database. Each unit gates on
|
||||
# scripts/wait-for-mount.sh, which waits and then refuses the start. A
|
||||
# relayout must be followed by `media-view.sh reap`
|
||||
# (see the Media Relayout page), which this is not.
|
||||
run_privileged bash scripts/media-view.sh install
|
||||
bash scripts/media-view.sh verify
|
||||
}
|
||||
|
||||
ensure_user_io_delegate() {
|
||||
# The media servers ask for an I/O share (IOWeight= in their quadlets) so a
|
||||
# library scan cannot starve playback or the array's other reader. Two system
|
||||
# drop-ins are what make that request real, and both are host state, so the
|
||||
# deployer places them:
|
||||
# user.slice.d/io-accounting.conf io enters user.slice's subtree
|
||||
# user@1000.service.d/io-delegate.conf the user manager gets the controller
|
||||
# Without the first, `io` never appears in user.slice/cgroup.subtree_control,
|
||||
# so no container under it can have an io.weight file at all; without the
|
||||
# second the user manager never asks for the controller. systemd hands
|
||||
# user@.service cpu/memory/pids by default and drops a line it cannot honour
|
||||
# without a word — IOWeight= on its own is inert config.
|
||||
local src dst
|
||||
for src in \
|
||||
dankserver/host/systemd/user.slice.d/io-accounting.conf \
|
||||
dankserver/host/systemd/user@1000.service.d/io-delegate.conf; do
|
||||
dst=/etc/systemd/system/${src#dankserver/host/systemd/}
|
||||
run_privileged mkdir -p "$(dirname "$dst")"
|
||||
if ! run_privileged cmp -s "$src" "$dst"; then
|
||||
run_privileged install -m 644 "$src" "$dst"
|
||||
run_privileged systemctl daemon-reload
|
||||
fi
|
||||
done
|
||||
# Accounting lands immediately; the delegation is read when user@1000.service
|
||||
# starts, and restarting that unit would kill every rootless container on this
|
||||
# host. So the share becomes real at the next reboot — say so out loud rather
|
||||
# than let a quadlet line read as live config.
|
||||
if ! systemctl show user@1000.service -p DelegateControllers | grep -qw io; then
|
||||
echo "NOTE: io is not delegated to user@1000.service yet - IOWeight= is"
|
||||
echo "NOTE: inert until the next reboot (see [[Quadlet Lifecycle]])"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_provider_keys() {
|
||||
# Jellyfin's metadata-provider keys live in the container-owned plugin
|
||||
# configuration under the data root, never in git; scripts/jellyfin-providers.sh
|
||||
# is what places them, reading the per-service host env file (the fleet
|
||||
# convention: grafana, caddy, pihole, copyparty, romm, seerr) that holds
|
||||
# TMDB_API_KEY= and TVDB_API_KEY=, mode 600.
|
||||
# Missing keys mean a keyless provider — a degraded library, not a broken
|
||||
# deploy — so this warns rather than failing the restart. But when the keys
|
||||
# ARE placed it must happen before the restart: the plugin and its keys are
|
||||
# read at start-up, so a restart first would boot the old state.
|
||||
local keys=/home/dank/Containers/jellyfin/provider-keys
|
||||
if [ -f "$keys" ]; then
|
||||
bash scripts/jellyfin-providers.sh install
|
||||
else
|
||||
echo "WARN: $keys is absent - provider keys NOT placed"
|
||||
echo "WARN: create it (TMDB_API_KEY=, TVDB_API_KEY=, chmod 600) and re-deploy jellyfin"
|
||||
fi
|
||||
}
|
||||
|
||||
deploy_jellyfin() {
|
||||
ensure_media_view
|
||||
ensure_user_io_delegate
|
||||
ensure_provider_keys
|
||||
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
||||
systemctl --user daemon-reload
|
||||
if ! systemctl --user is-active -q jellyfin.service; then
|
||||
@@ -1290,6 +1359,8 @@ deploy_copyparty() {
|
||||
}
|
||||
|
||||
deploy_plex() {
|
||||
ensure_media_view
|
||||
ensure_user_io_delegate
|
||||
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
||||
systemctl --user daemon-reload
|
||||
if ! systemctl --user is-active -q plex.service; then
|
||||
@@ -1362,11 +1433,17 @@ deploy_authentik() {
|
||||
# generated". Boot persistence comes from [Install] WantedBy=default.target
|
||||
# inside each .container file, which the generator honors at user-manager
|
||||
# start (linger must stay on for the deploy user).
|
||||
systemctl --user start authentik-postgres.service
|
||||
# RESTART, not start: `start` on an already-active unit is a no-op, so an
|
||||
# image bump would install the new digest and keep serving the old
|
||||
# container while every gate stayed green (measured 2026-10-30: server and
|
||||
# worker stayed on the previous release beside an outpost that moved,
|
||||
# because only the outpost handler restarts). The generator has already
|
||||
# re-rendered the unit from the .container, so a restart is what applies it.
|
||||
systemctl --user restart authentik-postgres.service
|
||||
wait_container_healthy authentik-postgres 120 || return 1
|
||||
systemctl --user start authentik-server.service
|
||||
systemctl --user restart authentik-server.service
|
||||
wait_container_healthy authentik-server 300 || return 1
|
||||
systemctl --user start authentik-worker.service
|
||||
systemctl --user restart authentik-worker.service
|
||||
}
|
||||
|
||||
deploy_authentik_outpost() {
|
||||
@@ -1482,7 +1559,7 @@ deploy_anubis() {
|
||||
# artifact would leave a restart-looping unit and a dark endpoint. The
|
||||
# port gate is the same as every cutover since 2026-09-16: nothing
|
||||
# starts until 7770 is released.
|
||||
LLM_DRAGOON_IMAGE="${LLM_DRAGOON_IMAGE:-ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a}"
|
||||
LLM_DRAGOON_IMAGE="${LLM_DRAGOON_IMAGE:-ghcr.io/peonist-ai/halogen-flash-server@sha256:e35133b206da3094cc4d70c78800b26235bd9124b9aeb0e029d4a7c153b9cff0}"
|
||||
# The payload dir is its own twin because the NPU lane's containment gate below
|
||||
# reads it; every artifact path under it stays a full literal as usual.
|
||||
LLM_DRAGOON_MODEL_DIR="${LLM_DRAGOON_MODEL_DIR:-/home/dank/models/halogen-v2-abliterated}"
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
# (/opt/halogen/npu/models.txt inside the image) and refuses to load a model
|
||||
# whose files do not match.
|
||||
#
|
||||
# The manifest below IS that catalog block (dumped from the pinned 0.16.2
|
||||
# image on 2026-10-19), so the files land in the layout the entrypoint
|
||||
# The manifest below IS that catalog block (dumped from the pinned 0.18.20
|
||||
# image on 2026-10-30; the 0.16.2 block this replaced named different device
|
||||
# programs — 10 `u*.elf` and a 5,086,496 B consts.hnpb — so a version bump
|
||||
# re-fetches the set or the entrypoint refuses the boot), so the files land in the layout the entrypoint
|
||||
# expects — <dir>/<model-id>/devices/*, <dir>/<model-id>/<model-id>.hnpw and
|
||||
# <dir>/<model-id>/tokenizer/tokenizer.json — and the endpoint can keep
|
||||
# /models read-only and open no outbound connection at boot (HALOGEN_DOWNLOAD
|
||||
@@ -57,21 +59,25 @@ manifest() {
|
||||
case "$1" in
|
||||
qwen3-embedding-0.6b)
|
||||
NPU_REPO="peonist-ai/halogen-npu-qwen3-embedding-0.6b"
|
||||
NPU_REV="4960fd9d4423dd15373ad21c83db48dbdb3b2798"
|
||||
NPU_REV="8fd44c9f18861618a509bea75c51ff8a37e3bd5d"
|
||||
FILES=(
|
||||
"devices/NOTICE.md 3248 0f267b3d2523ddf14997ee58660a492a9eaf10ccf6b08bf19dfdc85b9a478b91"
|
||||
"devices/consts.hnpb 5086496 ae430e41545a0b1012938cda373b0c450364d3be631111cca3a8c43679f93336"
|
||||
"devices/devices.hnpm 24047 4e9bca9d95d084fb89ce5b99f579a00666c8ca1efae2613c53e3a4878cc9f174"
|
||||
"devices/u0.elf 4931968 b38f165886cb037b7d3951d9cb878473eaba58a432acd45416ff85fd88aa4e46"
|
||||
"devices/u1.elf 5456000 b1811feee851b8af833b37721da92099d89772110815bbbe2c3fa1c276f0ff42"
|
||||
"devices/u2.elf 6505552 406ea9a95b470628a7382e376f4a72cdf4d7aa4c59b4ddf0a4bdd139d63d6ff2"
|
||||
"devices/u3.elf 6817216 bc205d51b523d770151a0c61968f7c70bf624412827580bb6fe0aaf4650dc2ee"
|
||||
"devices/u4.elf 7444032 a5a91338a2603acdcd5c11df532033fda2d3f5479ea44ae87f98fafbdff9a62d"
|
||||
"devices/u5.elf 7432416 6c2bf41835569e0eba25ded7c05c7264ae69c6700bdeb4791057d884f2a4c126"
|
||||
"devices/u6.elf 4941232 919d0038ca088eafddf195f3466ff38db27d3635be1cffa5d4d4b5140902a68e"
|
||||
"devices/u7.elf 5488304 d904cf8afae9428dd167960549e18cce5441745f18c62f8c50975dffc077d3c2"
|
||||
"devices/u8.elf 4941232 db3aef69b17825854ee0dd2df475125a3f5c872f95c686793d6af1ee83596fa7"
|
||||
"devices/u9.elf 5488304 d182727e551084db8fb9600441d8aa373c7f09a3d267ae0a5e921ef92326db1c"
|
||||
"devices/consts.hnpb 8495512 5af7c1f2f24d45a9054872bba882461cf7f6ffda832e785b700643d6467ad88b"
|
||||
"devices/devices.hnpm 35465 85d870f6b900de1829659955f0dc701d57984580f5e5ec251add5d6969f03e3d"
|
||||
"devices/u0.elf 2975008 f74df12eb8c79e4e90cbedcc1dd8f142219772d400e3a142fde8a95378b105eb"
|
||||
"devices/u1.elf 3266848 ac5a3f89f934951b315b62ab53e1d0a74dbe6396134192641c405d08da332754"
|
||||
"devices/u2.elf 4015328 5e4f0d3342f262816289866d7e8c966f56a9f938c2e12e7341718cc7312aa480"
|
||||
"devices/u3.elf 4326976 94cf5d269fa286d91c26bf4654cedf101c047d23c4495b74c9c129cdc33b210f"
|
||||
"devices/u4.elf 4953792 cbd2796050d4c675f5cb3da117111087d510173e26f262f1b36aab66dd49dc91"
|
||||
"devices/u5.elf 4940112 7bf6a4470546bb888da02af395c07d92a24fb6d43aef27eb2151b3a9eda19e4d"
|
||||
"devices/u6.elf 6174544 10b9d50b635993e1f9c23775c0b94901d922c1e433cd10910e9f97174235f5ac"
|
||||
"devices/u7.elf 2983776 e0b06063b7379d05c85b3f435b819d96fda1223d8025d6d15958854b5711ace9"
|
||||
"devices/u8.elf 3298656 3bad204f782a68b97e27c1172530b13d7fbb0b495fa95f9504530a1048560644"
|
||||
"devices/u9.elf 2983776 875e313280358ce116e2a51a29a2c42794b5a9fadb3d48db702c90d18d838c2c"
|
||||
"devices/u10.elf 3298656 ef2e0f399f149a5476955bcf233c6fa2e896fcef1f86ecb0d8113c952c41a8b2"
|
||||
"devices/u11.elf 3298656 68a2519eee2d6a61ccc4ca2007e3186d44c9eb4e81162d742fe2f1756ec5a528"
|
||||
"devices/u12.elf 3298656 2bdc6abfa58cac1d5795a3ef52fe8a9ab4e7c2a4792822d615499a913401f728"
|
||||
"devices/u13.elf 3298656 4a3ce14f7ba15987b9bcbb4a010083e8aef68cd87c20cbe43403a98263179fe2"
|
||||
"qwen3-embedding-0.6b.hnpw 813032248 b9f9901429df7b46ec17153aa3899f738adbea8e4356080da0c6e6586fb750d1"
|
||||
"tokenizer/tokenizer.json 11423705 def76fb086971c7867b829c23a26261e38d9d74e02139253b38aeb9df8b4b50a"
|
||||
) ;;
|
||||
@@ -99,7 +105,18 @@ for MODEL in ${MODELS//,/ }; do
|
||||
# 62 GiB fetch hit this on 2026-10-18).
|
||||
url=$(curl -sfIL -o /dev/null -w '%{url_effective}' "$HF/$NPU_REPO/resolve/$NPU_REV/$f") \
|
||||
|| { echo "cannot resolve $NPU_REPO/$f" >&2; exit 1; }
|
||||
curl -fL --retry 5 -C - -o "$path" "$url"
|
||||
# Resume ONLY into a genuinely partial file. A staged file of a
|
||||
# DIFFERENT size is an artifact of another revision (a version
|
||||
# bump re-cuts the device programs), and `-C -` against it
|
||||
# appends the tail to the old bytes: the result is the right
|
||||
# BYTE COUNT with the wrong content, which reads as a corrupt
|
||||
# program, not as a fetch fault (measured 2026-10-30 on
|
||||
# consts.hnpb). Fetch those from zero.
|
||||
resume=()
|
||||
if [ -f "$path" ] && [ "$(stat -c %s "$path")" -lt "$want" ]; then
|
||||
resume=(-C -)
|
||||
fi
|
||||
curl -fL --retry 5 "${resume[@]+"${resume[@]}"}" -o "$path" "$url"
|
||||
fi
|
||||
fi
|
||||
[ -f "$path" ] || { echo "MISSING $path (run without --verify-only)" >&2; exit 1; }
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
# The .hgn format is the lock: it loads ONLY in the flash-server line, so the
|
||||
# engine moves with the payload, never ahead of it.
|
||||
# Image digest-pinned (a tag pull silently re-points; the digest is what was
|
||||
# qualified — 0.16.2 is the version the model card names as its quickstart pin).
|
||||
FRANK_DRAGOON_IMAGE=ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a
|
||||
# qualified). 0.18.20 since 2026-10-30 (was 0.16.2, the model card's quickstart
|
||||
# pin) — the vendor has moved two minor versions and the payload lock is what
|
||||
# the deploy's health gate plus the boot ledger decide; rollback is the
|
||||
# previous digest, 0c61bf84…, on this same line.
|
||||
FRANK_DRAGOON_IMAGE=ghcr.io/peonist-ai/halogen-flash-server@sha256:e35133b206da3094cc4d70c78800b26235bd9124b9aeb0e029d4a7c153b9cff0
|
||||
# Container identity kept from the 27B era: the unit's ExecStop names it.
|
||||
FRANK_DRAGOON_CONTAINER=frank-dragoon-halogen
|
||||
# Predecessor identity the serve script clears if it is still holding the GPUs.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# frank-zealot — llama.cpp (PrismML fork build 10777, CUDA) on the RTX 3090,
|
||||
# :7771. Payload: Ternary-Bonsai-2-9B PQ2_0 with the MTP head in the file, plus
|
||||
# frank-zealot — llama.cpp (PrismML fork build 12118 = 0.7.0-dev, commit
|
||||
# 024474448, CUDA) on the RTX 3090, :7771. Payload: Ternary-Bonsai-2-9B PQ2_0 with the MTP head in the file, plus
|
||||
# the refusal-direction adapter applied at load.
|
||||
#
|
||||
# ONE OWNER: shape, payload identity, eras, laws and every measured number
|
||||
# live in serverAppConfigs-docs/LLM/frank-zealot.md. This file carries the
|
||||
# knobs and only the reason a reader editing THIS line cannot recover
|
||||
# elsewhere. Where the two disagree, this file wins on values.
|
||||
LLAMA_BIN=/home/dank/GitPackages/llama-prism-b10777/llama-server
|
||||
LLAMA_BIN=/home/dank/GitPackages/llama-prism-0244744/bin/llama-server
|
||||
# The fork is a LOCK, not a preference: custom tensor type 142 + prism.hadamard.*
|
||||
# metadata. Stock llama.cpp refuses the file; a loader ignoring the Hadamard
|
||||
# metadata dequantizes wrong, silently.
|
||||
@@ -64,7 +64,8 @@ CACHE_TYPE_V=q8_0
|
||||
# weights, so there is no --spec-draft-model. Its bill is pool × 4,096 B of
|
||||
# draft KV plus ~0.45 GiB of buffers and a tripled recurrent state — paid for
|
||||
# by CACHE_TYPE_V=q8_0 and UBATCH_SIZE=1024 above. Build gate: quantized V
|
||||
# beside MTP needs fork build >= b10776; LLAMA_BIN is 10777.
|
||||
# beside MTP needs fork build >= b10776; LLAMA_BIN is 12118 (b10777 is kept on
|
||||
# disk as the rollback path).
|
||||
# SPEC_TYPE=none is the rollback and strips the WHOLE set (--spec-type and
|
||||
# every --spec-draft-*); half a set has segfaulted at load.
|
||||
SPEC_TYPE=draft-mtp
|
||||
|
||||
Executable
+391
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env bash
|
||||
# Metadata-provider keys for Jellyfin on Dankserver, placed from the service's
|
||||
# host env file with the plugin artifact pinned by content hash.
|
||||
#
|
||||
# Jellyfin 12 ships TMDb, OMDb, StudioImages and MusicBrainz as CORE plugins:
|
||||
# they have no DLL, only a configuration file under the data root, and that
|
||||
# file is created by the running container. So the fleet's provider state lives
|
||||
# in two places the repo does not track —
|
||||
#
|
||||
# plugins/configurations/Jellyfin.Plugin.Tmdb.xml <TmdbApiKey /> (empty = keyless)
|
||||
# plugins/configurations/Jellyfin.Plugin.Tvdb.xml <ApiKey /> (needs the plugin)
|
||||
#
|
||||
# and both are owned by the container user. This tool is the only thing that
|
||||
# writes them: it is declarative, idempotent, and it never prints a key, so it
|
||||
# is safe to run while someone watches.
|
||||
#
|
||||
# Keys never enter git and never enter argv (a value in argv is world-readable
|
||||
# through /proc/<pid>/cmdline). They live in the per-service host env file that
|
||||
# is the fleet's convention for container credentials (grafana, caddy, pihole,
|
||||
# copyparty, romm, seerr), mode 600:
|
||||
#
|
||||
# /home/dank/Containers/jellyfin/provider-keys
|
||||
# TMDB_API_KEY=<key>
|
||||
# TVDB_API_KEY=<key>
|
||||
# TVDB_PIN=<pin> # optional: only a subscriber-shaped TVDB key
|
||||
# # needs one; a licensed key does not
|
||||
#
|
||||
# TVDB itself is not core in 12 (the metadata provider was cut in 10.9), so it
|
||||
# is installed from the official catalog, pinned three ways: sha256 of the
|
||||
# artifact, the plugin's own guid, and its version. A matching sha proves only
|
||||
# that the bytes are the bytes fetched — the guid check is what says it is the
|
||||
# official plugin, the version check is what says it is the one this file was
|
||||
# reviewed against.
|
||||
#
|
||||
# Subcommands
|
||||
# plan print the resolved shape and every pending change, touch nothing
|
||||
# install fetch+verify the plugin, then write the keys (backing up first)
|
||||
# verify prove the plugin and both keys are actually in place
|
||||
#
|
||||
# Nothing here restarts Jellyfin: the deployer owns that
|
||||
# (`scripts/deploy.sh dankserver --service jellyfin`), and the plugin is read at
|
||||
# start-up, so a key written while the server runs is not live until then.
|
||||
set -euo pipefail
|
||||
|
||||
# Jellyfin's data root on Dankserver (the rootless quadlet's config volume).
|
||||
JF_ROOT="${JF_ROOT:-/home/dank/Containers/jellyfin/config}"
|
||||
PROV_KEYS="${PROV_KEYS:-/home/dank/Containers/jellyfin/provider-keys}"
|
||||
TRASH_DIR="${TRASH_DIR:-$HOME/.AGENTTRASH/$(date +%Y%m%d)-jellyfin-providers}"
|
||||
|
||||
PLUGINS="$JF_ROOT/plugins"
|
||||
CONF="$PLUGINS/configurations"
|
||||
TMDB_XML="$CONF/Jellyfin.Plugin.Tmdb.xml"
|
||||
TVDB_XML="$CONF/Jellyfin.Plugin.Tvdb.xml"
|
||||
|
||||
# repo.jellyfin.org catalog entry for TheTVDB (owner: jellyfin), version 24.0.0.0,
|
||||
# targetAbi 12.0.0.0 — published 2026-09-09, verified against Jellyfin 12.4.0.
|
||||
TVDB_PLUGIN_URL="${TVDB_PLUGIN_URL:-https://repo.jellyfin.org/files/plugin/thetvdb/thetvdb_24.0.0.0.zip}"
|
||||
TVDB_PLUGIN_SHA256="${TVDB_PLUGIN_SHA256:-6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319}"
|
||||
TVDB_PLUGIN_GUID="${TVDB_PLUGIN_GUID:-a677c0da-fac5-4cde-941a-7134223f14c8}"
|
||||
TVDB_PLUGIN_VERSION="${TVDB_PLUGIN_VERSION:-24.0.0.0}"
|
||||
PLUGIN_DIR="thetvdb_${TVDB_PLUGIN_VERSION}"
|
||||
TVDB_DLL="Jellyfin.Plugin.Tvdb.dll"
|
||||
|
||||
die() {
|
||||
printf 'jellyfin-providers: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf 'usage: %s plan|install|verify\n' "$0" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# check_keys — refuse anything that is not a readable private file. An open
|
||||
# mode is an error, not a warning: a provider key readable by every user on the
|
||||
# box is the failure this check exists to catch.
|
||||
check_keys() {
|
||||
[ -f "$PROV_KEYS" ] ||
|
||||
die "$PROV_KEYS not found (create it with TMDB_API_KEY= and TVDB_API_KEY=, then chmod 600)"
|
||||
local mode
|
||||
mode="$(stat -c '%a' "$PROV_KEYS")"
|
||||
case "$mode" in
|
||||
600 | 400) ;;
|
||||
*) die "$PROV_KEYS is mode $mode; provider keys must be 600" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# keys_value <VAR> — the value of one variable in the env file, or nothing.
|
||||
keys_value() {
|
||||
awk -v k="$1" -F= '$1 == k { sub(/^[^=]*=/, ""); print; exit }' "$PROV_KEYS"
|
||||
}
|
||||
|
||||
# keys_len <VAR> — the length, and a refusal naming the variable when absent.
|
||||
# The value itself is never printed.
|
||||
keys_len() {
|
||||
local v
|
||||
v="$(keys_value "$1")"
|
||||
[ -n "$v" ] || die "$PROV_KEYS has no $1= line"
|
||||
printf '%s' "${#v}"
|
||||
}
|
||||
|
||||
# xml_value <file> <element> — the element's text, empty when it is absent or
|
||||
# self-closing.
|
||||
xml_value() {
|
||||
python3 - "$1" "$2" <<'PYEOF'
|
||||
import re, sys
|
||||
src = open(sys.argv[1], encoding="utf-8").read()
|
||||
m = re.search(r"<%s(?:\s*/>|>(.*?)</%s>)" % (sys.argv[2], sys.argv[2]), src, re.S)
|
||||
print(m.group(1).strip() if (m and m.group(1)) else "")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# xml_secret <file> <element> <var> — set <element> to that variable from the
|
||||
# env file. The python process reads the env file itself, so the value never
|
||||
# appears in argv.
|
||||
xml_secret() {
|
||||
python3 - "$1" "$2" "$3" "$PROV_KEYS" <<'PYEOF'
|
||||
import os, re, sys, tempfile
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
xml_path, elem, var, keys_path = sys.argv[1:5]
|
||||
val = ""
|
||||
for line in open(keys_path, encoding="utf-8"):
|
||||
key, sep, rest = line.partition("=")
|
||||
if sep and key.strip() == var:
|
||||
val = rest.strip()
|
||||
break
|
||||
if not val:
|
||||
raise SystemExit(f"{keys_path} has no {var}= line")
|
||||
|
||||
src = open(xml_path, encoding="utf-8").read()
|
||||
pat = re.compile(r"([ \t]*)<%s(?:\s*/>|>.*?</%s>)" % (elem, elem), re.S)
|
||||
text = escape(val)
|
||||
if pat.search(src):
|
||||
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
|
||||
else:
|
||||
out = src.replace("</Configuration>", f" <{elem}>{text}</{elem}>\n</Configuration>", 1)
|
||||
if out == src:
|
||||
raise SystemExit(f"{xml_path}: no place to write <{elem}>")
|
||||
|
||||
d, base = os.path.split(xml_path)
|
||||
fd, tmp = tempfile.mkstemp(prefix=base + ".", dir=d)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(out)
|
||||
os.chmod(tmp, os.stat(xml_path).st_mode & 0o777)
|
||||
os.replace(tmp, xml_path)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# plugin_meta <zip> — "guid version" out of the artifact's own meta.json.
|
||||
plugin_meta() {
|
||||
python3 - "$1" <<'PYEOF'
|
||||
import json, sys, zipfile
|
||||
meta = json.loads(zipfile.ZipFile(sys.argv[1]).read("meta.json").decode("utf-8"))
|
||||
print(meta.get("guid", ""), meta.get("version", ""))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
check_root() {
|
||||
# Never fabricate a data root: creating plugins/ beside a typo'd path would
|
||||
# install a plugin Jellyfin never loads and hide the operator's mistake.
|
||||
[ -d "$CONF" ] || die "no Jellyfin configuration directory at $CONF (is JF_ROOT right?)"
|
||||
}
|
||||
|
||||
check_keys_report() {
|
||||
check_keys
|
||||
# A refusal inside a command substitution used as a printf argument would
|
||||
# be swallowed, so the guard runs as its own statement.
|
||||
keys_len TMDB_API_KEY >/dev/null
|
||||
keys_len TVDB_API_KEY >/dev/null
|
||||
printf '= keys read from %s (mode %s)\n' "$PROV_KEYS" "$(stat -c '%a' "$PROV_KEYS")"
|
||||
}
|
||||
|
||||
# The plugin's state, as one line in the shared vocabulary.
|
||||
plugin_state() {
|
||||
if [ -d "$PLUGINS/$PLUGIN_DIR" ]; then
|
||||
printf '= %s already installed\n' "$PLUGIN_DIR"
|
||||
return 0
|
||||
fi
|
||||
local other
|
||||
other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
|
||||
if [ -n "$other" ]; then
|
||||
printf '+ install %s (a different version is present: %s)\n' "$PLUGIN_DIR" "$other"
|
||||
return 0
|
||||
fi
|
||||
printf '+ install %s from %s\n' "$PLUGIN_DIR" "${TVDB_PLUGIN_URL#*://}"
|
||||
}
|
||||
|
||||
tmdb_state() {
|
||||
if [ ! -f "$TMDB_XML" ]; then
|
||||
printf '! %s is missing — save the TMDb plugin settings once in the UI, then re-run\n' "$TMDB_XML" >&2
|
||||
return 1
|
||||
fi
|
||||
local now want
|
||||
now="$(xml_value "$TMDB_XML" TmdbApiKey)"
|
||||
want="$(keys_value TMDB_API_KEY)"
|
||||
if [ -n "$now" ] && [ "$now" = "$want" ]; then
|
||||
printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
|
||||
elif [ -n "$now" ]; then
|
||||
printf '+ replace the TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
|
||||
else
|
||||
printf '+ set TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
|
||||
fi
|
||||
}
|
||||
|
||||
tvdb_state() {
|
||||
local now
|
||||
if [ -f "$TVDB_XML" ]; then
|
||||
now="$(xml_value "$TVDB_XML" ApiKey)"
|
||||
if [ -n "$now" ]; then
|
||||
printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
|
||||
else
|
||||
printf '+ fill the empty ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
|
||||
fi
|
||||
else
|
||||
printf '+ create Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
|
||||
fi
|
||||
}
|
||||
|
||||
write_plugin() {
|
||||
local tmp guid version other
|
||||
other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
|
||||
[ -z "$other" ] ||
|
||||
die "another TheTVDB version is already installed ($other); move it to the trash first — two copies of one plugin means two loads"
|
||||
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/thetvdb.XXXXXX.zip")"
|
||||
# The pins are checked before anything is unpacked, so a bad artifact never
|
||||
# reaches the plugins directory.
|
||||
curl -fsSL --max-time 300 -o "$tmp" "$TVDB_PLUGIN_URL" ||
|
||||
{ rm -f "$tmp"; die "could not fetch $TVDB_PLUGIN_URL"; }
|
||||
local got
|
||||
got="$(sha256sum "$tmp" | cut -d' ' -f1)"
|
||||
if [ "$got" != "$TVDB_PLUGIN_SHA256" ]; then
|
||||
rm -f "$tmp"
|
||||
die "artifact sha256 is $got, pinned $TVDB_PLUGIN_SHA256 — refusing to install"
|
||||
fi
|
||||
read -r guid version <<<"$(plugin_meta "$tmp")"
|
||||
if [ "$guid" != "$TVDB_PLUGIN_GUID" ]; then
|
||||
rm -f "$tmp"
|
||||
die "artifact guid is $guid, expected the official $TVDB_PLUGIN_GUID — refusing to install"
|
||||
fi
|
||||
if [ "$version" != "$TVDB_PLUGIN_VERSION" ]; then
|
||||
rm -f "$tmp"
|
||||
die "artifact version is $version, pinned $TVDB_PLUGIN_VERSION — refusing to install"
|
||||
fi
|
||||
mkdir -p "$PLUGINS/$PLUGIN_DIR"
|
||||
python3 -m zipfile -e "$tmp" "$PLUGINS/$PLUGIN_DIR/"
|
||||
rm -f "$tmp"
|
||||
[ -f "$PLUGINS/$PLUGIN_DIR/$TVDB_DLL" ] ||
|
||||
die "$PLUGIN_DIR has no $TVDB_DLL — the artifact does not match this plugin"
|
||||
printf '+ installed %s\n' "$PLUGIN_DIR"
|
||||
}
|
||||
|
||||
backup_to_trash() {
|
||||
local f="$1"
|
||||
mkdir -p "$TRASH_DIR"
|
||||
local dest="$TRASH_DIR/$(basename "$f").$(date +%H%M%S)"
|
||||
cp -p "$f" "$dest"
|
||||
printf '+ previous copy kept at %s\n' "$dest"
|
||||
}
|
||||
|
||||
cmd_write_keys() {
|
||||
local now want
|
||||
KEYS_CHANGED=0
|
||||
# TMDb: the running container owns this file and every setting in it. Only
|
||||
# the key element is touched, so country, language and adult settings
|
||||
# survive the edit.
|
||||
[ -f "$TMDB_XML" ] ||
|
||||
die "$TMDB_XML is missing — save the TMDb plugin settings once in the UI, then re-run"
|
||||
now="$(xml_value "$TMDB_XML" TmdbApiKey)"
|
||||
keys_len TMDB_API_KEY >/dev/null
|
||||
want="$(keys_value TMDB_API_KEY)"
|
||||
if [ "$now" = "$want" ]; then
|
||||
printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
|
||||
else
|
||||
backup_to_trash "$TMDB_XML"
|
||||
xml_secret "$TMDB_XML" TmdbApiKey TMDB_API_KEY
|
||||
printf '+ set TmdbApiKey (%s chars)\n' "${#want}"
|
||||
KEYS_CHANGED=1
|
||||
fi
|
||||
|
||||
# TVDB: never overwrite a config the operator saved through the UI.
|
||||
if [ -f "$TVDB_XML" ]; then
|
||||
now="$(xml_value "$TVDB_XML" ApiKey)"
|
||||
if [ -n "$now" ]; then
|
||||
printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
|
||||
else
|
||||
keys_len TVDB_API_KEY >/dev/null
|
||||
backup_to_trash "$TVDB_XML"
|
||||
xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
|
||||
if [ -n "$(keys_value TVDB_PIN)" ] && [ -z "$(xml_value "$TVDB_XML" Pin)" ]; then
|
||||
xml_secret "$TVDB_XML" Pin TVDB_PIN
|
||||
fi
|
||||
printf '+ filled the ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
|
||||
KEYS_CHANGED=1
|
||||
fi
|
||||
else
|
||||
keys_len TVDB_API_KEY >/dev/null
|
||||
cat >"$TVDB_XML" <<XML
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<ApiKey />
|
||||
</Configuration>
|
||||
XML
|
||||
xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
|
||||
if [ -n "$(keys_value TVDB_PIN)" ]; then
|
||||
xml_secret "$TVDB_XML" Pin TVDB_PIN
|
||||
fi
|
||||
printf '+ created Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
|
||||
KEYS_CHANGED=1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_plan() {
|
||||
check_root
|
||||
check_keys_report
|
||||
printf 'root %s\n' "$JF_ROOT"
|
||||
plugin_state
|
||||
tmdb_state
|
||||
tvdb_state
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
check_root
|
||||
check_keys_report
|
||||
KEYS_CHANGED=0
|
||||
if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
|
||||
write_plugin
|
||||
KEYS_CHANGED=1
|
||||
else
|
||||
printf '= %s already installed\n' "$PLUGIN_DIR"
|
||||
fi
|
||||
cmd_write_keys
|
||||
if [ "$KEYS_CHANGED" -eq 1 ]; then
|
||||
# Nothing is restarted here on purpose: this host may be playing media
|
||||
# right now, and both the plugin and its keys are read at start-up.
|
||||
printf 'next: scripts/deploy.sh dankserver --service jellyfin (the plugin and keys load at start-up)\n'
|
||||
else
|
||||
printf '= no change\n'
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_verify() {
|
||||
check_root
|
||||
local bad=0
|
||||
if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
|
||||
printf 'MISSING plugin directory %s (thetvdb not installed)\n' "$PLUGIN_DIR" >&2
|
||||
bad=1
|
||||
else
|
||||
local dlls abi
|
||||
dlls="$(find "$PLUGINS/$PLUGIN_DIR" -maxdepth 1 -name '*.dll' | wc -l)"
|
||||
[ "$dlls" -ge 1 ] || {
|
||||
printf 'MISSING %s inside %s\n' "$TVDB_DLL" "$PLUGIN_DIR" >&2
|
||||
bad=1
|
||||
}
|
||||
abi="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("targetAbi","?"))' \
|
||||
"$PLUGINS/$PLUGIN_DIR/meta.json" 2>/dev/null || echo '?')"
|
||||
printf 'plugin %s: %s dll(s), targetAbi %s\n' "$PLUGIN_DIR" "$dlls" "$abi"
|
||||
fi
|
||||
local pair elem n
|
||||
for pair in "$TMDB_XML|TmdbApiKey" "$TVDB_XML|ApiKey"; do
|
||||
elem="${pair##*|}"
|
||||
if [ ! -f "${pair%%|*}" ]; then
|
||||
printf 'MISSING %s\n' "${pair%%|*}" >&2
|
||||
bad=1
|
||||
continue
|
||||
fi
|
||||
if ! python3 -c 'import sys, xml.dom.minidom; xml.dom.minidom.parse(sys.argv[1])' "${pair%%|*}"; then
|
||||
printf 'UNPARSEABLE %s\n' "${pair%%|*}" >&2
|
||||
bad=1
|
||||
continue
|
||||
fi
|
||||
n="$(xml_value "${pair%%|*}" "$elem")"
|
||||
if [ -z "$n" ]; then
|
||||
printf 'MISSING key: %s has no %s value\n' "$(basename "${pair%%|*}")" "$elem" >&2
|
||||
bad=1
|
||||
else
|
||||
printf '%s %s chars\n' "$elem" "${#n}"
|
||||
fi
|
||||
done
|
||||
[ "$bad" -eq 0 ] || return 1
|
||||
printf 'providers configured\n'
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
plan) cmd_plan ;;
|
||||
install) cmd_install ;;
|
||||
verify) cmd_verify ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
Executable
+513
@@ -0,0 +1,513 @@
|
||||
#!/usr/bin/env python3
|
||||
"""media-apply.py — the only thing that moves movie-library files.
|
||||
|
||||
Reads the plan written by scripts/media-plan.py (rows: `action src dst kind`,
|
||||
plus `mkdir` rows) and performs it with rename(2) — bytes are never copied, so
|
||||
an interrupted run has destroyed nothing. Every run is resumable and every run
|
||||
is undoable: the journal is a write-ahead log, one JSON line per intent, fsynced
|
||||
BEFORE the rename it authorises. That ordering is the whole design: a crash
|
||||
leaves either an intent whose move happened or an intent whose move never ran,
|
||||
and both are recognised on the next run. A file that moved with no journal line
|
||||
cannot have been moved by this tool, and saying so is better than guessing.
|
||||
|
||||
Four subcommands:
|
||||
|
||||
apply --plan PLAN [--root ROOT] [--journal J] [--apply]
|
||||
no --apply => report only and write NOTHING, not even folders
|
||||
rollback --journal J [--root ROOT] [--apply]
|
||||
back to the paths the census saw; folders this run created are
|
||||
removed only when empty, never anything else
|
||||
verify --plan PLAN --journal J [--root ROOT]
|
||||
every planned file is at its target with the journalled byte count
|
||||
sweep --journal J [--root ROOT] [--apply]
|
||||
removes the folders THIS RUN emptied — the journal has to show a
|
||||
file that moved out of one, so a folder someone made by hand can
|
||||
never be removed by this tool, and rollback puts them back
|
||||
|
||||
Refusals (exit 2, before the first rename, with nothing touched): a target
|
||||
already occupied by a foreign file, a source that has gone, a duplicate target,
|
||||
a path outside --root, a journal that was already rolled back, a move that would
|
||||
cross a filesystem (rename cannot do it and a silent copy at 24 TB would not).
|
||||
|
||||
--force relaxes only the guards that are opinion (journal already rolled back,
|
||||
paths outside --root); it never allows overwriting a file.
|
||||
|
||||
--metadata-view names the overlay the media servers read their library through
|
||||
(default /var/lib/media-meta/view). The sidecar follow-up printed after an apply
|
||||
or rollback is conditioned on that path being a mountpoint: where no view is
|
||||
mounted the servers bind the array itself, the sidecars moved with the media,
|
||||
and a reap order would point at a directory that is not there.
|
||||
|
||||
The journal defaults to `media-relayout.jsonl` BESIDE the library root, never
|
||||
inside it: a log inside the library is a file Jellyfin will index.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
DEFAULT_ROOT = os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies")
|
||||
|
||||
|
||||
class Refuse(Exception):
|
||||
"""A gate the operator has to see; every refusal leaves the library alone."""
|
||||
|
||||
|
||||
def journal_default(root):
|
||||
return os.path.join(os.path.dirname(os.path.abspath(root)), "media-relayout.jsonl")
|
||||
|
||||
|
||||
class Journal:
|
||||
"""Append-only JSONL of intents. Writes are fsynced before the tool acts.
|
||||
|
||||
``batch`` is how many intents may be written before one fsync covers them.
|
||||
The rule the file exists for is unchanged — an intent is on disk before the
|
||||
move it authorises runs — so the caller must call ``sync()`` before it
|
||||
performs a batch's renames; only the number of flushes falls. 1 = a flush
|
||||
per intent (the original behaviour).
|
||||
"""
|
||||
|
||||
def __init__(self, path, writing, batch=1):
|
||||
self.path, self.writing = path, writing
|
||||
self.batch = max(1, int(batch))
|
||||
self.records = []
|
||||
if os.path.exists(path):
|
||||
for line in open(path):
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.records.append(json.loads(line))
|
||||
self._fh = None
|
||||
self._unsynced = 0
|
||||
# (src, dst) index: already() is called three times per move, so an
|
||||
# any() over every record written so far made a 58k-move pass quadratic.
|
||||
self._intents = {(r.get("src"), r.get("dst"))
|
||||
for r in self.records if r.get("op") == "move"}
|
||||
|
||||
def already(self, dst, src):
|
||||
"""An intent for this exact (src, dst) was written by an earlier run."""
|
||||
return (src, dst) in self._intents
|
||||
|
||||
@property
|
||||
def rolled_back(self):
|
||||
return any(r.get("op") == "rollback" for r in self.records)
|
||||
|
||||
def record(self, **fields):
|
||||
self.records.append(fields)
|
||||
if fields.get("op") == "move":
|
||||
self._intents.add((fields.get("src"), fields.get("dst")))
|
||||
if not self.writing:
|
||||
return
|
||||
if self._fh is None:
|
||||
self._fh = open(self.path, "a")
|
||||
self._fh.write(json.dumps(fields) + "\n")
|
||||
self._fh.flush()
|
||||
self._unsynced += 1
|
||||
if self._unsynced >= self.batch:
|
||||
self.sync()
|
||||
|
||||
def sync(self):
|
||||
"""Make every intent written so far durable. No-op when nothing pending."""
|
||||
if not self.writing or self._fh is None or not self._unsynced:
|
||||
return
|
||||
os.fsync(self._fh.fileno())
|
||||
self._unsynced = 0
|
||||
|
||||
def close(self):
|
||||
if self._fh:
|
||||
self.sync()
|
||||
self._fh.close()
|
||||
|
||||
|
||||
def read_plan(path, root, force):
|
||||
"""Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root."""
|
||||
mkdirs, keeps, moves = [], [], []
|
||||
with open(path) as fh:
|
||||
header = fh.readline()
|
||||
if not header.startswith("action\t"):
|
||||
raise Refuse(f"{path} is not a plan (first column is "
|
||||
f"{header.split(chr(9))[0]!r}, expected 'action') —"
|
||||
" regenerate it with scripts/media-plan.py")
|
||||
for line in fh:
|
||||
f = line.rstrip("\n").split("\t")
|
||||
if f[0] == "mkdir":
|
||||
mkdirs.append(f[2])
|
||||
elif f[0] == "keep":
|
||||
keeps.append(f[1])
|
||||
elif f[0] == "move":
|
||||
moves.append((f[1], f[2], f[3] if len(f) > 3 else ""))
|
||||
if not force:
|
||||
for p in mkdirs + keeps + [p for m in moves for p in m[:2]]:
|
||||
if p and not (p == root or p.startswith(root + os.sep)):
|
||||
raise Refuse(f"{p} is outside --root {root}"
|
||||
" (a plan built for another library?)")
|
||||
return mkdirs, keeps, moves
|
||||
|
||||
|
||||
# The media servers may read the library through the SSD overlay view
|
||||
# (scripts/media-view.sh). A relayout moves bytes on the raw array, so sidecars
|
||||
# the servers wrote through that view stay at the old coordinates — naming the
|
||||
# follow-up here is the point of use for that fact, but only where the view
|
||||
# exists: where it is not mounted the servers bind the array itself, the
|
||||
# sidecars moved with the media, and a reap order points at a directory that
|
||||
# is not there. See the Media Metadata View page for the host's state.
|
||||
METADATA_VIEW = "/var/lib/media-meta/view"
|
||||
METADATA_HINT = ("note: sidecars written through the metadata view did not follow"
|
||||
" these moves — next: scripts/media-view.sh reap")
|
||||
|
||||
|
||||
def metadata_note(args):
|
||||
"""Print the sidecar follow-up only when the overlay is really mounted."""
|
||||
if os.path.ismount(args.metadata_view):
|
||||
print(METADATA_HINT)
|
||||
|
||||
|
||||
def do_apply(args, root, journal, mkdirs, keeps, moves):
|
||||
"""Preflight everything, then folders, then renames. Never a partial start."""
|
||||
problems = []
|
||||
for p in keeps:
|
||||
if not os.path.exists(p):
|
||||
problems.append(f"missing: keep row source is gone: {p}")
|
||||
seen = {}
|
||||
for src, dst, _kind in moves:
|
||||
if src == dst:
|
||||
problems.append(f"no-op: move row whose target is its source: {src}")
|
||||
if dst in seen:
|
||||
problems.append(f"double-claim: {dst} claimed by {seen[dst]} and {src}")
|
||||
seen[dst] = src
|
||||
if journal.already(dst, src) and os.path.exists(dst) and not os.path.exists(src):
|
||||
continue # this intent already ran
|
||||
if not os.path.exists(src):
|
||||
if os.path.exists(dst):
|
||||
problems.append(f"missing: {src} is gone and {dst} carries no journal"
|
||||
" line — it moved outside this tool, refusing to guess")
|
||||
else:
|
||||
problems.append(f"missing: {src} is gone and {dst} is not there either")
|
||||
continue
|
||||
if os.path.exists(dst):
|
||||
problems.append(f"occupied: target already holds another file: {dst}")
|
||||
if problems:
|
||||
print(f"refusing: {len(problems)} problem(s), nothing moved")
|
||||
for p in sorted(problems)[:20]:
|
||||
print(" " + p)
|
||||
if len(problems) > 20:
|
||||
print(f" ... and {len(problems) - 20} more")
|
||||
return 2
|
||||
|
||||
if not args.apply:
|
||||
print(f"dry run: {len(moves)} moves, {len(mkdirs)} folders, {len(keeps)} files stay"
|
||||
" — nothing written (pass --apply to run)")
|
||||
for src, dst, _k in moves[:5]:
|
||||
print(f" would move {src} -> {dst}")
|
||||
return 0
|
||||
|
||||
batch = max(1, args.batch)
|
||||
made = {r.get("dst") for r in journal.records if r.get("op") == "mkdir"}
|
||||
dirs_new = 0
|
||||
queued = []
|
||||
|
||||
def drain_dirs():
|
||||
"""Create the queued folders only after their intents are durable."""
|
||||
nonlocal dirs_new
|
||||
if not queued:
|
||||
return
|
||||
journal.sync()
|
||||
for d in queued:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
dirs_new += 1
|
||||
queued.clear()
|
||||
|
||||
for d in sorted(mkdirs, key=lambda q: q.count(os.sep)):
|
||||
if d in made or os.path.isdir(d):
|
||||
continue
|
||||
journal.record(op="mkdir", dst=d)
|
||||
queued.append(d)
|
||||
if len(queued) >= batch:
|
||||
drain_dirs()
|
||||
drain_dirs()
|
||||
|
||||
# same-filesystem gate: rename cannot cross a device, and a silent copy of a
|
||||
# library this size is not an option anyone would choose.
|
||||
cross = []
|
||||
for src, dst, _k in moves:
|
||||
if journal.already(dst, src) and os.path.exists(dst) and not os.path.exists(src):
|
||||
continue
|
||||
parent = os.path.dirname(dst)
|
||||
if os.path.exists(parent) and os.stat(parent).st_dev != os.stat(src).st_dev:
|
||||
cross.append(dst)
|
||||
if cross:
|
||||
print(f"refusing: {len(cross)} moves would cross a filesystem"
|
||||
f" (first: {cross[0]}) — this tool only renames")
|
||||
return 2
|
||||
|
||||
moved = done = 0
|
||||
pending = []
|
||||
|
||||
def drain_moves():
|
||||
"""Perform the queued renames only after their intents are on disk."""
|
||||
nonlocal moved
|
||||
if not pending:
|
||||
return
|
||||
journal.sync()
|
||||
for s, d in pending:
|
||||
os.rename(s, d)
|
||||
moved += 1
|
||||
pending.clear()
|
||||
|
||||
for src, dst, _kind in moves:
|
||||
if journal.already(dst, src):
|
||||
if os.path.exists(dst) and not os.path.exists(src):
|
||||
done += 1
|
||||
continue
|
||||
if os.path.exists(src) and not os.path.exists(dst):
|
||||
os.rename(src, dst) # the intent ran, the move did not
|
||||
moved += 1
|
||||
continue
|
||||
st = os.stat(src)
|
||||
journal.record(op="move", src=src, dst=dst, bytes=st.st_size, mode=st.st_mode,
|
||||
mtime=st.st_mtime, ino=st.st_ino, dev=st.st_dev)
|
||||
pending.append((src, dst))
|
||||
if len(pending) >= batch:
|
||||
drain_moves()
|
||||
drain_moves()
|
||||
print(f"applied: {moved} moved, {done} already done in an earlier run,"
|
||||
f" {dirs_new} folders created, {len(keeps)} files stayed put")
|
||||
print(f"journal: {journal.path} — undo with: media-apply.py rollback"
|
||||
f" --journal {journal.path} --root {root} --apply")
|
||||
metadata_note(args)
|
||||
return 0
|
||||
|
||||
|
||||
def do_verify(root, journal, moves, keeps):
|
||||
"""Every planned file sits at its target carrying the journalled bytes."""
|
||||
journalled = {(r["src"], r["dst"]): r for r in journal.records
|
||||
if r.get("op") == "move"}
|
||||
todo = moves or [(r["src"], r["dst"], "") for r in journal.records
|
||||
if r.get("op") == "move"]
|
||||
bad, missing, bytes_total = [], [], 0
|
||||
for src, dst, _kind in todo:
|
||||
rec = journalled.get((src, dst))
|
||||
if not os.path.exists(dst):
|
||||
missing.append(dst)
|
||||
continue
|
||||
size = os.path.getsize(dst)
|
||||
bytes_total += size
|
||||
if rec and rec.get("bytes") not in (None, size):
|
||||
bad.append(f"{dst}: {size} bytes on disk, {rec['bytes']} at move time")
|
||||
if os.path.exists(src) and src != dst:
|
||||
bad.append(f"{src}: still in place as well as at {dst}")
|
||||
missing += [p for p in keeps if not os.path.exists(p)]
|
||||
if bad or missing:
|
||||
print(f"verify FAILED: {len(missing)} absent, {len(bad)} wrong")
|
||||
for line in (missing + bad)[:20]:
|
||||
print(" " + line)
|
||||
return 2
|
||||
empty = [r["dst"] for r in journal.records
|
||||
if r.get("op") == "mkdir" and os.path.isdir(r["dst"])
|
||||
and not os.listdir(r["dst"])]
|
||||
print(f"verified: {len(todo)} files at their targets"
|
||||
f" ({bytes_total / 2**40:.2f} TiB), {len(keeps)} kept")
|
||||
if empty:
|
||||
print(f"note: {len(empty)} folders are empty (left in place — see the trash law)")
|
||||
return 0
|
||||
|
||||
|
||||
def sweep_targets(root, journal):
|
||||
"""The folders this run emptied, deepest first — never one it did not touch.
|
||||
|
||||
Eligibility is evidence, not appearance. A folder is a target only when the
|
||||
journal shows a file moved out of it or out of something beneath it, it holds
|
||||
nothing but empty directories now, it is not a folder this run created, and no
|
||||
journalled destination lives under it. A symlink anywhere inside disqualifies
|
||||
the whole subtree: this tool never follows a link, and deleting the folder
|
||||
that holds one would delete someone's mount point.
|
||||
|
||||
Proof carries downward: once a folder qualifies, every empty directory inside
|
||||
it qualifies too. A release's junk (``Screens/``, ``Sample/``) is never itself
|
||||
a move source, so without that rule it holds its emptied parent on disk
|
||||
forever — the parent can never be removed while the child stands.
|
||||
"""
|
||||
moves = [r for r in journal.records if r.get("op") == "move"]
|
||||
made = {r["dst"] for r in journal.records if r.get("op") == "mkdir"}
|
||||
# An rmdir INTENT that failed left its folder standing, and the next sweep
|
||||
# must be able to finish it: only a folder that is really gone is out of
|
||||
# scope. Counting every intent as done made 146 live survivors permanent.
|
||||
gone = {r["path"] for r in journal.records if r.get("op") == "rmdir"
|
||||
and not os.path.exists(r["path"])}
|
||||
dsts = [r["dst"] for r in moves]
|
||||
marked = set()
|
||||
for r in moves:
|
||||
d = os.path.dirname(r["src"])
|
||||
while d.startswith(root + os.sep):
|
||||
marked.add(d)
|
||||
d = os.path.dirname(d)
|
||||
|
||||
def removable(d):
|
||||
if d in made or d in gone or os.path.islink(d):
|
||||
return False
|
||||
if any(p == d or p.startswith(d + os.sep) for p in dsts):
|
||||
return False
|
||||
try:
|
||||
entries = os.listdir(d)
|
||||
except OSError:
|
||||
return False
|
||||
for e in entries:
|
||||
c = os.path.join(d, e)
|
||||
if os.path.islink(c) or not os.path.isdir(c) or not removable(c):
|
||||
return False
|
||||
return True
|
||||
|
||||
roots = [d for d in marked if removable(d)]
|
||||
targets = set(roots)
|
||||
for d in roots:
|
||||
for dirpath, dirnames, _ in os.walk(d):
|
||||
for c in dirnames:
|
||||
p = os.path.join(dirpath, c)
|
||||
if removable(p):
|
||||
targets.add(p)
|
||||
return sorted(targets, key=lambda q: (-q.count(os.sep), q))
|
||||
|
||||
|
||||
def do_sweep(args, root, journal):
|
||||
"""Remove the empty husks an apply left standing at the library root."""
|
||||
moves = [r for r in journal.records if r.get("op") == "move"]
|
||||
if not moves:
|
||||
raise Refuse("the journal holds no moves — a sweep follows an apply, it"
|
||||
" does not guess at emptiness")
|
||||
outside = sorted({p for r in moves for p in (r["src"], r["dst"])
|
||||
if not (p == root or p.startswith(root + os.sep))})
|
||||
if outside and not args.force:
|
||||
raise Refuse(f"{len(outside)} journalled paths are outside --root {root}"
|
||||
f" (first: {outside[0]}) — this journal belongs to another"
|
||||
" library")
|
||||
targets = sweep_targets(root, journal)
|
||||
if not targets:
|
||||
print("swept: 0 folders — nothing this run emptied is still standing empty")
|
||||
return 0
|
||||
if not args.apply:
|
||||
print(f"dry run: {len(targets)} folders this run emptied would be removed"
|
||||
" (pass --apply to run)")
|
||||
for d in targets[:5]:
|
||||
print(f" would remove {d}")
|
||||
return 0
|
||||
removed = 0
|
||||
for d in targets:
|
||||
journal.record(op="rmdir", path=d)
|
||||
try:
|
||||
os.rmdir(d)
|
||||
except OSError as e:
|
||||
print(f"left in place: {d} ({e})")
|
||||
continue
|
||||
removed += 1
|
||||
print(f"swept: {removed} folders removed, {len(targets) - removed} left in place")
|
||||
print(f"journal: {journal.path} — rollback recreates them with their files")
|
||||
return 0
|
||||
|
||||
|
||||
def do_rollback(args, root, journal):
|
||||
"""Undo every journalled move, newest first, after a full preflight."""
|
||||
moves = [r for r in journal.records if r.get("op") == "move"]
|
||||
made = [r["dst"] for r in journal.records if r.get("op") == "mkdir"]
|
||||
swept = [r["path"] for r in journal.records if r.get("op") == "rmdir"]
|
||||
problems = []
|
||||
for r in reversed(moves):
|
||||
src, dst = r["src"], r["dst"]
|
||||
if not args.force and not (src.startswith(root + os.sep)
|
||||
or dst.startswith(root + os.sep)):
|
||||
problems.append(f"outside --root: {dst}")
|
||||
continue
|
||||
if os.path.exists(dst) and os.path.exists(src):
|
||||
problems.append(f"occupied: both paths exist, refusing to choose:"
|
||||
f" {src} | {dst}")
|
||||
elif os.path.exists(dst):
|
||||
pass
|
||||
elif os.path.exists(src):
|
||||
continue # already undone
|
||||
else:
|
||||
problems.append(f"missing: {dst} is gone and {src} never came back")
|
||||
if problems:
|
||||
print(f"refusing: {len(problems)} problem(s), nothing moved back")
|
||||
for p in problems[:20]:
|
||||
print(" " + p)
|
||||
return 2
|
||||
if not args.apply:
|
||||
print(f"dry run: would move {len(moves)} files back to their census paths"
|
||||
f" and recreate {len(set(swept))} swept folders (pass --apply)")
|
||||
return 0
|
||||
undone = 0
|
||||
recreated = 0
|
||||
for d in sorted(set(swept), key=lambda q: q.count(os.sep)):
|
||||
if not os.path.isdir(d):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
recreated += 1
|
||||
for r in reversed(moves):
|
||||
if os.path.exists(r["dst"]) and not os.path.exists(r["src"]):
|
||||
os.makedirs(os.path.dirname(r["src"]), exist_ok=True)
|
||||
os.rename(r["dst"], r["src"])
|
||||
undone += 1
|
||||
dirs_gone = 0
|
||||
for d in sorted(set(made), key=lambda q: -q.count(os.sep)):
|
||||
if d == root or not os.path.isdir(d) or os.listdir(d):
|
||||
continue
|
||||
try:
|
||||
os.rmdir(d)
|
||||
dirs_gone += 1
|
||||
except OSError as e:
|
||||
print(f"left in place: {d} ({e})")
|
||||
journal.record(op="rollback", undone=undone, dirs_removed=dirs_gone,
|
||||
t=datetime.datetime.now(datetime.timezone.utc).isoformat())
|
||||
print(f"rolled back: {undone} files back where the census found them,"
|
||||
f" {dirs_gone} empty folders removed, {recreated} swept folders recreated")
|
||||
metadata_note(args)
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="apply or undo a media relayout plan")
|
||||
ap.add_argument("cmd", choices=["apply", "rollback", "verify", "sweep"])
|
||||
ap.add_argument("--plan")
|
||||
ap.add_argument("--root", default=DEFAULT_ROOT)
|
||||
ap.add_argument("--journal")
|
||||
ap.add_argument("--apply", action="store_true", help="actually change the library")
|
||||
ap.add_argument("--force", action="store_true", help="relax the opinion guards")
|
||||
ap.add_argument("--metadata-view", default=METADATA_VIEW,
|
||||
help="overlay the media servers read through; the sidecar"
|
||||
" note prints only when this path is a mountpoint")
|
||||
ap.add_argument("--batch", type=int, default=64,
|
||||
help="journal intents per fsync (1 = flush before every move)."
|
||||
" An intent is still durable before the move it authorises;"
|
||||
" only the number of flushes falls")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = os.path.abspath(args.root)
|
||||
if not os.path.isdir(root):
|
||||
raise Refuse(f"no library at {root}")
|
||||
journal_path = args.journal or journal_default(root)
|
||||
journal = Journal(journal_path, args.apply, batch=args.batch)
|
||||
try:
|
||||
if args.cmd in ("rollback", "verify", "sweep") and not os.path.exists(journal_path):
|
||||
raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}")
|
||||
if args.cmd in ("rollback", "sweep") and not args.force and journal.rolled_back:
|
||||
raise Refuse("this journal has already been rolled back"
|
||||
" (--force to replay it)")
|
||||
if args.cmd in ("apply", "verify") and not args.plan:
|
||||
raise Refuse(f"{args.cmd} needs --plan")
|
||||
mkdirs, keeps, moves = (read_plan(args.plan, root, args.force)
|
||||
if args.plan else ([], [], []))
|
||||
if args.cmd == "apply":
|
||||
return do_apply(args, root, journal, mkdirs, keeps, moves)
|
||||
if args.cmd == "verify":
|
||||
return do_verify(root, journal, moves, keeps)
|
||||
if args.cmd == "sweep":
|
||||
return do_sweep(args, root, journal)
|
||||
return do_rollback(args, root, journal)
|
||||
finally:
|
||||
journal.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Refuse as r:
|
||||
print(f"refusing: {r}")
|
||||
sys.exit(2)
|
||||
Executable
+425
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""media-audiobooks.py — a plan that re-files the audiobook library into one folder per book.
|
||||
|
||||
media-audiobooks.py --root DIR --enum TSV --rows TSV [--out TSV] [--exceptions FILE]
|
||||
|
||||
Plex reads this library and Jellyfin does not: its music scanner has no audiobook
|
||||
concept, and the tree is indexed entirely by Plex, which matched 58,975 of the
|
||||
62,879 files here into its Artist/Album/Track shape. So the authority is
|
||||
`scripts/media-census.py audiobooks` — the BOOK (album row), its NARRATOR (that
|
||||
book's single artist row) and its CHAPTERS (track rows) — and never the folder a
|
||||
release pack made. The plan is:
|
||||
|
||||
Audiobooks/<Narrator>/<Book> (<year>)/<the file's own name>.<ext>
|
||||
... the same folder, " (2)" on the name, when two chapters
|
||||
of one book carry the same name
|
||||
|
||||
What that buys is the one property both media servers' book scanners assume: a
|
||||
folder holds exactly one book. Today 212 top-level folders are a mix of
|
||||
author-named shelves and collection packs, and the books inside the packs are
|
||||
split across `… [Part 1]`, `… [Part 2]` folders three or four deep.
|
||||
|
||||
Three laws here are the opposite of what a music shelf does, and each is forced
|
||||
by what the rows actually hold:
|
||||
|
||||
* A chapter is NEVER renumbered. 7,556 of the 10,485 books contain at least one
|
||||
chapter whose index Plex does not know, so a number written into a name would
|
||||
be invented — and it would buy nothing, because both servers order playback by
|
||||
the embedded track tag, not by the file name. A wrong number in a name is worse
|
||||
than none: it is the number a human later trusts. A file already carrying its
|
||||
own number keeps it, untouched.
|
||||
* The shelf is the NARRATOR, not the author. Every book has exactly one artist row
|
||||
and it is the narrator; the author survives only inside 10% of the album titles
|
||||
(`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving the
|
||||
author would mean parsing titles for one book in ten and inventing it for the
|
||||
rest. One rule that always resolves beats a shelf that is half author and half
|
||||
narrator; the author stays visible in the book folder's own name, which is the
|
||||
title Plex shows.
|
||||
* Nothing is guessed at. A chapter row with no book or no narrator parks — a
|
||||
tag-scan placeholder (`[Unknown Album]`) names neither, since unrelated books
|
||||
share it and filing on one merges them, and so does a chapter number the ripper
|
||||
mistagged as the artist (`0.4`). An audio file Plex never matched parks, a
|
||||
video container parks, and a loose file in a folder holding two books parks.
|
||||
|
||||
Nothing is deleted and nothing is overwritten. The output is a list of rename(2)
|
||||
intents for `scripts/media-apply.py`, whose preflight refuses any target that is
|
||||
already claimed and whose journal is the rollback.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
# What a chapter file can be. .m4b is the audiobook container; the rest are the
|
||||
# audio a pack ships when it was ripped as music.
|
||||
AUDIO_EXT = {".mp3", ".m4a", ".m4b", ".ogg", ".oga", ".opus", ".wav", ".flac",
|
||||
".wma", ".aiff", ".aif", ".m4v", ".aac", ".mka"}
|
||||
# Never an audiobook, whatever the library it sits in says: Plex reads these as
|
||||
# video, and renaming one into an audio extension would claim it is a chapter.
|
||||
VIDEO_EXT = {".mkv", ".mp4", ".webm", ".avi", ".mov", ".mpg", ".mpeg", ".ts",
|
||||
".m2ts", ".vob", ".ifo", ".wmv", ".iso", ".img", ".bdmv", ".ogm",
|
||||
".divx"}
|
||||
|
||||
# Tag-scan placeholders. These strings are not identities: 1,106 chapters from
|
||||
# 14 unrelated top-level folders carry the album `[Unknown Album]`, so filing on
|
||||
# them would merge books that have nothing to do with each other — the exact
|
||||
# shape this relayout exists to make impossible. Such a chapter parks with the
|
||||
# path it has today and the fix belongs in its tags. `Various Artists` is NOT a
|
||||
# placeholder: that is what a multi-cast read carries, and the one book here
|
||||
# with 1,189 chapters under it is genuinely one book.
|
||||
PLACEHOLDER_BOOK = {"[unknown album]", "unknown album", "no title", "untitled",
|
||||
"[untitled]", "unknown title", "n/a", "-"}
|
||||
PLACEHOLDER_NARRATOR = {"[unknown artist]", "unknown artist", "unknown author",
|
||||
"<unknown>", "[unknown]", "unknown"}
|
||||
|
||||
|
||||
def identity(value, placeholders, needs_letter=False):
|
||||
"""The tag value Plex matched, when it names something; '' when it does not.
|
||||
|
||||
A trailing parenthesized group is stripped for the test only, because rippers
|
||||
glue timestamps to the placeholder (`Unknown album (03/03/2015 21:39:55)`).
|
||||
|
||||
`needs_letter` is for a NARRATOR alone: a shelf is a person's name, and 272
|
||||
chapters here carry a chapter number in the artist tag (`0.4`, `31`). The same
|
||||
test must never judge a BOOK title, which may honestly be `1984`.
|
||||
"""
|
||||
v = (value or "").strip()
|
||||
bare = re.sub(r"\s*\([^()]*\)\s*$", "", v).strip().casefold()
|
||||
if bare in placeholders or (needs_letter and not any(c.isalpha() for c in v)):
|
||||
return ""
|
||||
return v
|
||||
|
||||
|
||||
# The characters Plex documents as breaking playback. They are dropped, not
|
||||
# substituted: a replacement character is a guess at what the title meant.
|
||||
BAD_CHARS = set('<>:"/\\|?*')
|
||||
TRASH_DIR = re.compile(r"(^|/)(\.AGENTTRASH|\.thumbnails|\.Trash-\d+|@eaDir"
|
||||
r"|lost\+found)(/|$)")
|
||||
|
||||
ARGS = None
|
||||
HELD = {} # path -> (bytes, ext)
|
||||
ROWS = {} # path -> the chapter row Plex matched
|
||||
PLACED = {} # chapter path -> the destination the plan gave it
|
||||
CLAIMED = set() # every destination path the plan has handed out
|
||||
MOVES, KEEPS = [], []
|
||||
PARKED = defaultdict(list) # reason -> [path]
|
||||
NOTES = defaultdict(list) # section -> [line]
|
||||
AUDIO_BY_DIR = defaultdict(set) # source dir -> chapter files a row names
|
||||
CHILD_OF = defaultdict(set) # dir -> the dirs directly below it holding chapters
|
||||
FOLDERS_SEEN = {} # (dir, depth) -> book folders placed below it
|
||||
SUBTREE_DEPTH = 2 # how far below a folder its chapters may sit
|
||||
BOOKS = {} # (narrator, book, year) -> the book group
|
||||
|
||||
AMBIGUOUS = object()
|
||||
|
||||
|
||||
def dominant(values):
|
||||
"""The spelling the rows mostly agree on: the most frequent value, ties broken
|
||||
by sort so the answer never depends on the order rows arrived in."""
|
||||
counts = Counter(values)
|
||||
return min(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0]
|
||||
|
||||
|
||||
def rel(path):
|
||||
"""The path as seen from the library root."""
|
||||
return os.path.relpath(path, ARGS.root)
|
||||
|
||||
|
||||
def safe(name):
|
||||
"""A name every client can carry: no reserved character, nothing unprintable,
|
||||
no trailing dot or space, and short enough to rename."""
|
||||
out = "".join(c for c in (name or "")
|
||||
if c not in BAD_CHARS and (c.isprintable() or c == " "))
|
||||
out = re.sub(r"\s+", " ", out).strip().rstrip(". ")
|
||||
while out and len(out.encode("utf-8", "ignore")) > 200:
|
||||
out = out[:-4].rstrip()
|
||||
return out
|
||||
|
||||
|
||||
def assign(src, folder, name, ext, kind, note):
|
||||
"""Book one file into `folder` under `name`, renamed if that exact destination
|
||||
is already taken — by another file in the plan, or by a file standing there
|
||||
today. Two files claiming one path is the fault that costs a re-run of a 60k
|
||||
plan, and so is a plan that only works if the renames happen in the right
|
||||
order: the executor refuses any target that is occupied, so an occupied path
|
||||
is never a destination, even when its occupant is scheduled to leave.
|
||||
A file already standing where the contract puts it is a keep, not a move."""
|
||||
stem = safe(name) or "file"
|
||||
dst = os.path.join(folder, stem + ext)
|
||||
if dst == src:
|
||||
CLAIMED.add(dst.lower()) # it is standing on its own target
|
||||
KEEPS.append((src, dst, kind))
|
||||
return dst
|
||||
# A file already in this folder under this name — or under the numbered
|
||||
# variant the collision rule gives it — keeps the name it has. Renumbering it
|
||||
# every run would turn the plan into a shuffle instead of a fixed point.
|
||||
base = os.path.splitext(os.path.basename(src))[0]
|
||||
if os.path.dirname(src) == folder and (base == stem
|
||||
or base.startswith(stem + " (")):
|
||||
CLAIMED.add(src.lower())
|
||||
KEEPS.append((src, src, kind))
|
||||
return src
|
||||
n = 1
|
||||
while dst.lower() in CLAIMED:
|
||||
n += 1
|
||||
dst = os.path.join(folder, f"{stem} ({n}){ext}")
|
||||
if n > 1:
|
||||
NOTES[note].append(rel(dst))
|
||||
CLAIMED.add(dst.lower())
|
||||
MOVES.append((src, dst, kind))
|
||||
return dst
|
||||
|
||||
|
||||
def park(path, reason):
|
||||
"""Hold a file where it is, under the park folder, keeping its relative path."""
|
||||
PARKED[reason].append(path)
|
||||
dst = os.path.join(ARGS.park_dir, rel(path))
|
||||
if dst != path:
|
||||
CLAIMED.add(dst.lower())
|
||||
MOVES.append((path, dst, "park"))
|
||||
|
||||
|
||||
def under_park(path):
|
||||
"""True for a file already parked: that pile is for a human to review, and
|
||||
re-parking it every run would only push it deeper."""
|
||||
return path.startswith(ARGS.park_dir + os.sep)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ books
|
||||
def group_books():
|
||||
"""Every matched chapter into one group per (narrator, book, year).
|
||||
|
||||
The key is the identity Plex asserts in its own database, not the folder the
|
||||
file sits in: one book arrives as four part folders inside a collection pack,
|
||||
and two copies of one book in one pack are one book to Plex, whose album item
|
||||
IS that narrator, title and year. The year is part of the key so a 1965
|
||||
recording and a 2020 recording that share a title stay two books.
|
||||
"""
|
||||
for path, r in sorted(ROWS.items()):
|
||||
if path not in HELD or under_park(path) or TRASH_DIR.search(path):
|
||||
continue
|
||||
d = os.path.dirname(path)
|
||||
AUDIO_BY_DIR[d].add(path)
|
||||
CHILD_OF[os.path.dirname(d)].add(d) # so a cover above Part 1/Part 2 sees both
|
||||
narrator = identity(r["narrator"], PLACEHOLDER_NARRATOR, needs_letter=True)
|
||||
book = identity(r["book"], PLACEHOLDER_BOOK)
|
||||
if not book:
|
||||
park(path, "a chapter row that names no book — Plex's album title is a "
|
||||
"placeholder like [Unknown Album], so filing on it would "
|
||||
"merge unrelated books onto one shelf")
|
||||
continue
|
||||
if not narrator:
|
||||
park(path, "a chapter row that names no narrator — the artist row is a "
|
||||
"placeholder like [Unknown Artist] or a chapter number a "
|
||||
"ripper mistagged as the artist, so no shelf owns it")
|
||||
continue
|
||||
key = (narrator.casefold(), book.casefold(), (r["year"] or "").strip())
|
||||
g = BOOKS.setdefault(key, {"narrators": [], "books": [], "year": key[2],
|
||||
"members": [], "indexed": 0})
|
||||
g["members"].append(path)
|
||||
g["narrators"].append(narrator)
|
||||
g["books"].append(book)
|
||||
if str(r["track"] or "").isdigit():
|
||||
g["indexed"] += 1
|
||||
|
||||
|
||||
def book_folder(g):
|
||||
"""The shelf Plex's own rows name: <narrator>/<book> (<year>). None when a
|
||||
name is made of characters no path can hold — inventing a shelf is filing it
|
||||
wrong, so those chapters park instead."""
|
||||
narrator = safe(dominant(g["narrators"]))
|
||||
book = safe(dominant(g["books"]))
|
||||
if not narrator or not book:
|
||||
return None
|
||||
return os.path.join(ARGS.root, narrator,
|
||||
f"{book} ({g['year']})" if g["year"] else book)
|
||||
|
||||
|
||||
def place_books():
|
||||
"""One folder per book; every chapter keeps the name it has today."""
|
||||
for key in sorted(BOOKS):
|
||||
g = BOOKS[key]
|
||||
folder = book_folder(g)
|
||||
if folder is None:
|
||||
for path in sorted(g["members"]):
|
||||
park(path, "a book or narrator Plex spells with characters no "
|
||||
"folder name can hold")
|
||||
continue
|
||||
for path in sorted(g["members"]):
|
||||
ext = HELD[path][1]
|
||||
base = os.path.splitext(os.path.basename(path))[0]
|
||||
# The name the ripper wrote, unchanged: the track tag inside the file
|
||||
# is what orders playback, and 72% of the books here have chapters no
|
||||
# row numbers, so any number added here would be invented.
|
||||
PLACED[path] = assign(path, folder, base, ext, "chapter",
|
||||
"two chapters of one book carried the same name")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- sidecars
|
||||
def folders_below(d, depth):
|
||||
"""The book folders the chapters in `d`, or within `depth` directories below
|
||||
it, were placed in. Memoised, because every loose file in a big collection
|
||||
folder asks the same question about it."""
|
||||
if (d, depth) not in FOLDERS_SEEN:
|
||||
seen = {os.path.dirname(PLACED[a]) for a in AUDIO_BY_DIR.get(d, ())
|
||||
if a in PLACED}
|
||||
if depth:
|
||||
for child in CHILD_OF.get(d, ()):
|
||||
seen |= folders_below(child, depth - 1)
|
||||
FOLDERS_SEEN[(d, depth)] = seen
|
||||
return FOLDERS_SEEN[(d, depth)]
|
||||
|
||||
|
||||
def owner_folder(d):
|
||||
"""The book folder that owns this directory — the first place up the tree
|
||||
where chapters sit, in the directory itself or within SUBTREE_DEPTH levels
|
||||
below it, which is how a pack whose parts are `… [Part 1]`/`… [Part 2]` owns
|
||||
the cover art lying above them. None when no chapter is near enough to say,
|
||||
AMBIGUOUS when the chapters there belong to more than one book."""
|
||||
while d.startswith(ARGS.root + os.sep) and d != ARGS.root:
|
||||
folders = folders_below(d, SUBTREE_DEPTH)
|
||||
if folders:
|
||||
return next(iter(folders)) if len(folders) == 1 else AMBIGUOUS
|
||||
d = os.path.dirname(d)
|
||||
return None
|
||||
|
||||
|
||||
def place_other():
|
||||
"""Everything that is not a chapter Plex matched: covers, booklets, EPUBs,
|
||||
logs, checksums, playlists — and the clutter that is none of those."""
|
||||
for path, (_size, ext) in sorted(HELD.items()):
|
||||
if path in ROWS or under_park(path) or TRASH_DIR.search(path):
|
||||
continue
|
||||
d = os.path.dirname(path)
|
||||
base = os.path.splitext(os.path.basename(path))[0]
|
||||
if ext in VIDEO_EXT:
|
||||
park(path, "a video container inside the audiobook library: Plex reads "
|
||||
"it as video, and renaming it would claim it is a chapter")
|
||||
elif ext in AUDIO_EXT:
|
||||
park(path, "an audio file no chapter row names — it is not in the "
|
||||
"library Plex matched, so nothing says which book it is")
|
||||
else:
|
||||
owner = owner_folder(d)
|
||||
if owner is AMBIGUOUS:
|
||||
park(path, "a file whose folder holds more than one book, so "
|
||||
"nothing says which one it belongs to")
|
||||
elif owner:
|
||||
assign(path, owner, base, ext,
|
||||
"playlist" if ext in (".m3u", ".m3u8") else "sidecar",
|
||||
"one book folder would have carried two files of the same"
|
||||
" sidecar name")
|
||||
else:
|
||||
park(path, "not an audiobook file, and no book's folder lies with"
|
||||
" it to carry it")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- outputs
|
||||
def write_plan():
|
||||
"""mkdir rows for every destination folder and its whole chain, then the moves.
|
||||
Parents first: the executor creates directories in the order they are listed."""
|
||||
chain = set()
|
||||
for _s, dst, _k in MOVES:
|
||||
d = os.path.dirname(dst)
|
||||
while d and d != ARGS.root and d != os.path.dirname(d):
|
||||
chain.add(d)
|
||||
d = os.path.dirname(d)
|
||||
dirs = sorted(chain, key=lambda p: (p.count("/"), p))
|
||||
with open(ARGS.out, "w") as fh:
|
||||
fh.write("action\tsrc\tdst\tkind\n")
|
||||
for d in dirs:
|
||||
fh.write(f"mkdir\t\t{d}\tdir\n")
|
||||
for src, dst, kind in sorted(MOVES, key=lambda m: (m[1], m[0])):
|
||||
fh.write(f"move\t{src}\t{dst}\t{kind}\n")
|
||||
for src, dst, kind in sorted(KEEPS, key=lambda m: (m[1], m[0])):
|
||||
fh.write(f"keep\t{src}\t{dst}\t{kind}\n")
|
||||
parked = [m for m in MOVES if m[2] == "park"]
|
||||
sized = sum(HELD[s][0] for s, _d, _k in MOVES if s in HELD)
|
||||
print(f"plan: {len(MOVES) - len(parked)} moves, {len(parked)} parked"
|
||||
f" ({sized / 2**40:.2f} TiB touched), {len(KEEPS)} already placed,"
|
||||
f" {len(dirs)} folders -> {ARGS.out}")
|
||||
|
||||
|
||||
def write_exceptions():
|
||||
with open(ARGS.exceptions, "w") as fh:
|
||||
fh.write(f"Audiobook relayout exceptions for {ARGS.root}\n\n")
|
||||
fh.write(f"Everything below is parked under {ARGS.park_dir}, keeping the\n"
|
||||
"relative path it has today. Nothing is deleted and nothing is\n"
|
||||
"overwritten: a parked file is one rename from wherever the plan is\n"
|
||||
"pointed next.\n\n")
|
||||
for reason in sorted(PARKED, key=lambda r: -len(PARKED[r])):
|
||||
rs = PARKED[reason]
|
||||
fh.write(f"{len(rs):>6} files {reason}\n")
|
||||
for p in sorted(rs, key=rel)[:400]:
|
||||
fh.write(f" {rel(p)}\n")
|
||||
if len(rs) > 400:
|
||||
fh.write(f" … and {len(rs) - 400} more\n")
|
||||
fh.write("\n")
|
||||
for section in sorted(NOTES, key=lambda s: -len(NOTES[s])):
|
||||
lines = sorted(set(NOTES[section]))
|
||||
fh.write(f"{len(lines):>6} {section}\n")
|
||||
for line in lines[:200]:
|
||||
fh.write(f" {line}\n")
|
||||
if len(lines) > 200:
|
||||
fh.write(f" … and {len(lines) - 200} more\n")
|
||||
fh.write("\n")
|
||||
unindexed = sorted(f"{safe(dominant(g['narrators']))}/{safe(dominant(g['books']))}"
|
||||
for g in BOOKS.values()
|
||||
if 0 < g["indexed"] < len(g["members"]))
|
||||
if unindexed:
|
||||
fh.write(f"{len(unindexed):>6} books have chapters Plex does not number. Their\n")
|
||||
fh.write(" files keep the names they carry: playback order comes from the\n"
|
||||
" track tag inside each file, and a number written here would be\n"
|
||||
" invented for the chapters with no index. Fix it in the tags, not\n"
|
||||
" in the names — Plex re-reads tags on a scan and never names files.\n\n")
|
||||
fh.write(" Playlists move with their book and their contents are never\n"
|
||||
" rewritten: the paths inside an .m3u go stale.\n")
|
||||
print(f"exceptions -> {ARGS.exceptions}")
|
||||
|
||||
|
||||
def main():
|
||||
global ARGS
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--root", default=os.environ.get("MEDIA_AUDIOBOOKS_ROOT",
|
||||
"/mnt/entropy/Audiobooks"))
|
||||
ap.add_argument("--enum", default="/tmp/audiobooks-enum.tsv",
|
||||
help="the enumerate TSV (bytes, ext, path)")
|
||||
ap.add_argument("--rows", default="/tmp/plex-audiobooks.tsv",
|
||||
help="the chapter rows scripts/media-census.py audiobooks wrote")
|
||||
ap.add_argument("--out", default="/tmp/audiobooks-plan.tsv")
|
||||
ap.add_argument("--exceptions", default="/tmp/audiobooks-exceptions.txt")
|
||||
ap.add_argument("--park", default="_unsorted",
|
||||
help="the folder inside the root that holds what cannot be filed")
|
||||
ARGS = ap.parse_args()
|
||||
ARGS.root = os.path.normpath(ARGS.root)
|
||||
ARGS.park_dir = os.path.join(ARGS.root, ARGS.park)
|
||||
read_inputs()
|
||||
# Every path a file occupies today is claimed before a single destination is
|
||||
# handed out: the executor refuses an occupied target, so a plan that aimed at
|
||||
# one would only fail after the renames that were supposed to clear it.
|
||||
CLAIMED.update(p.lower() for p in HELD)
|
||||
group_books()
|
||||
place_books()
|
||||
place_other()
|
||||
write_plan()
|
||||
write_exceptions()
|
||||
|
||||
|
||||
def read_inputs():
|
||||
with open(ARGS.enum) as fh:
|
||||
for line in fh:
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) >= 3:
|
||||
HELD[parts[2]] = (int(parts[0]), parts[1].lower())
|
||||
with open(ARGS.rows, newline="") as fh:
|
||||
for row in csv.DictReader(fh, delimiter="\t"):
|
||||
if row.get("file"):
|
||||
ROWS[row["file"]] = row
|
||||
gone = [p for p in ROWS if p not in HELD]
|
||||
if gone:
|
||||
NOTES["a chapter row whose file the census no longer sees"].extend(gone)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+526
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Read-only census of a media library — the inputs a relayout plan is built from.
|
||||
|
||||
media-census.py enumerate [--root DIR] [--out TSV]
|
||||
media-census.py probe [--enum TSV] [--out TSV] [--jobs N]
|
||||
media-census.py plex [--root DIR] [--db PATH] [--out TSV]
|
||||
media-census.py jellyfin [--root DIR] [--db PATH] [--container PREFIX] [--out TSV]
|
||||
media-census.py audiobooks [--root DIR] [--db PATH] [--out TSV]
|
||||
|
||||
Run it on the machine that mounts the library. Nothing here writes inside the
|
||||
library and nothing is deleted; the only outputs are the TSVs below, and the
|
||||
scripts/media-plan.py and scripts/media-shows.py read them instead of touching media.
|
||||
|
||||
enum bytes <TAB> ext <TAB> path every file under the root
|
||||
probed bytes <TAB> path <TAB> dur <TAB> vcodec <TAB> WxH <TAB> fps <TAB> vbit
|
||||
<TAB> pix <TAB> audio <TAB> subs <TAB> spare <TAB> format
|
||||
one row per video-container file; `audio` is "codec:lang+codec:lang",
|
||||
`subs` the subtitle-stream count, `spare` the audio streams carrying
|
||||
neither a default nor a forced tag
|
||||
plex file <TAB> leaf_title <TAB> … (see PLEX_COLS)
|
||||
the titles Plex already matched — the authority over any filename guess
|
||||
|
||||
jellyfin file <TAB> series <TAB> year <TAB> season <TAB> episode <TAB> extra
|
||||
<TAB> tmdb <TAB> title <TAB> tvdb (see JF_COLS)
|
||||
one row per episode Jellyfin has matched and numbered, its path mapped
|
||||
from the container's view back to the host; `year`, `tmdb` and `tvdb`
|
||||
are the SERIES' values, `title` is the episode title the row carries,
|
||||
`extra` is Jellyfin's own extra type when the row is bonus material
|
||||
rather than an episode
|
||||
|
||||
music file <TAB> artist <TAB> album <TAB> year <TAB> disc <TAB> track <TAB> title
|
||||
<TAB> mb <TAB> performer (see MUSIC_COLS)
|
||||
one row per track Jellyfin has tagged: the tag read is what the row IS,
|
||||
so `artist` is the album artist (the track artist when that is empty) and
|
||||
`album`/`year` are the album item's provider values when the folder is an
|
||||
album item and the tags' otherwise; `mb` is the album's MusicBrainz
|
||||
release group, `performer` the track artist the tags carry
|
||||
|
||||
audiobooks file <TAB> narrator <TAB> book <TAB> year <TAB> track <TAB> chapter
|
||||
(see AB_COLS)
|
||||
one row per chapter Plex matched under the audiobook root: `book` is the
|
||||
album row Plex built from the files' own tags, `narrator` that book's
|
||||
artist row — Plex keeps exactly one artist per book, which is why it, and
|
||||
not an author parsed out of a title, names the shelf. `track` is empty
|
||||
wherever Plex has no chapter number, which is most of this library.
|
||||
|
||||
`probe` is the slow step (every container is opened for its header, ~10 min for
|
||||
4,800 files on a spindle array at 6 workers) so it is `nice`d: a census beside a
|
||||
live service must not starve it. `enumerate` is a stat pass, so re-censusing
|
||||
before a plan is cheap — and a plan is only ever as good as these files.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
# Containers ffprobe can read. .iso/.img are deliberately NOT here: a disc image is
|
||||
# a mounted volume, not a stream, and the planner treats it as a movie file instead.
|
||||
PROBE_EXT = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts", ".m2ts", ".mpg",
|
||||
".mpeg", ".webm", ".wmv", ".divx", ".ogm", ".bdmv", ".vob"}
|
||||
# Trash and thumbnail caches are not library content, and this repo's trash policy
|
||||
# means .AGENTTRASH can sit inside a movie folder at any time.
|
||||
SKIP_DIR = re.compile(r"(^|/)(\.AGENTTRASH|\.thumbnails|\.Trash-\d+|@eaDir|lost\+found)(/|$)")
|
||||
PLEX_DB = ("/var/lib/plexmediaserver/Library/Application Support/Plex Media Server"
|
||||
"/Plug-in Support/Databases/com.plexapp.plugins.library.db")
|
||||
PLEX_COLS = ["file", "leaf_title", "leaf_orig", "leaf_edition", "leaf_year",
|
||||
"parent_title", "parent_orig", "parent_year", "parent_edition",
|
||||
"container", "vcodec", "acodec", "width", "height"]
|
||||
PLEX_SQL = """
|
||||
select p.file, coalesce(mi.title,''), coalesce(mi.original_title,''),
|
||||
coalesce(mi.edition_title,''), coalesce(mi.year,0),
|
||||
coalesce(par.title,''), coalesce(par.original_title,''),
|
||||
coalesce(par.year,0), coalesce(par.edition_title,''),
|
||||
m.container, m.video_codec, m.audio_codec, m.width, m.height
|
||||
from media_parts p
|
||||
join media_items m on m.id = p.media_item_id
|
||||
join metadata_items mi on mi.id = m.metadata_item_id
|
||||
left join metadata_items par on par.id = mi.parent_id
|
||||
where p.file like ? and p.deleted_at is null and mi.deleted_at is null
|
||||
"""
|
||||
JF_DB = "/home/dank/Containers/jellyfin/config/data/jellyfin.db"
|
||||
JF_COLS = ["file", "series", "year", "season", "episode", "extra",
|
||||
"tmdb", "title", "tvdb"]
|
||||
# Jellyfin stores the entity's .NET class as its type; the TV tree is these three.
|
||||
JF_TV = ("MediaBrowser.Controller.Entities.TV.Episode",
|
||||
"MediaBrowser.Controller.Entities.TV.Season",
|
||||
"MediaBrowser.Controller.Entities.TV.Series")
|
||||
JF_SQL = ("""select Id, Path, IndexNumber, ParentIndexNumber, ExtraType,
|
||||
IsVirtualItem, Name from BaseItems where Type = ? and Path like ?""")
|
||||
|
||||
# The music tree is these two entity classes. The track row carries the tags
|
||||
# Jellyfin read out of the file; the album row carries what the provider matched,
|
||||
# which is the better folder name of the two.
|
||||
JF_AUDIO = "MediaBrowser.Controller.Entities.Audio.Audio"
|
||||
JF_ALBUM = "MediaBrowser.Controller.Entities.Audio.MusicAlbum"
|
||||
MUSIC_COLS = ["file", "artist", "album", "year", "disc", "track", "title", "mb",
|
||||
"performer"]
|
||||
MUSIC_SQL = ("""select b.Path, b.AlbumArtists, b.Artists, b.Album, b.ProductionYear,
|
||||
b.ParentIndexNumber, b.IndexNumber, b.Name, b.Id,
|
||||
a.Name, a.ProductionYear, a.Id
|
||||
from BaseItems b
|
||||
left join BaseItems a on a.Id = b.ParentId and a.Type = ?
|
||||
where b.Type = ? and b.Path like ?""")
|
||||
# The album's identity, in preference order. A release group is the album; a bare
|
||||
# release id is one reissue of it.
|
||||
MUSIC_PROVIDERS = ("MusicBrainzReleaseGroup", "MusicBrainzAlbum")
|
||||
|
||||
# Plex's audiobook tree is the same Artist/Album/Track shape as its music tree:
|
||||
# the leaf (10) is a CHAPTER, its parent (9) is the BOOK, and that book's artist
|
||||
# (8) is the NARRATOR. The track's own `index` is exported because the exceptions
|
||||
# report counts how many books have chapters Plex does not number — the reason the
|
||||
# relayout refuses to invent numbers into file names.
|
||||
PLEX_ARTIST, PLEX_ALBUM, PLEX_TRACK = 8, 9, 10
|
||||
AB_COLS = ["file", "narrator", "book", "year", "track", "chapter"]
|
||||
AB_SQL = f"""
|
||||
select p.file, coalesce(ar.title,''), coalesce(al.title,''), coalesce(al.year,0),
|
||||
coalesce(mi."index",-1), coalesce(mi.title,'')
|
||||
from media_parts p
|
||||
join media_items m on m.id = p.media_item_id
|
||||
join metadata_items mi on mi.id = m.metadata_item_id
|
||||
join metadata_items al on al.id = mi.parent_id and al.metadata_type = {PLEX_ALBUM}
|
||||
left join metadata_items ar on ar.id = al.parent_id
|
||||
and ar.metadata_type = {PLEX_ARTIST}
|
||||
where p.file like ? and p.deleted_at is null and mi.deleted_at is null
|
||||
and al.deleted_at is null
|
||||
"""
|
||||
|
||||
def open_plex_ro(db):
|
||||
"""Plex's library, read the way Plex writes it.
|
||||
|
||||
`immutable=1` tells sqlite the file never changes, so it skips the
|
||||
write-ahead log: every row a scan has committed but not yet checkpointed
|
||||
is invisible, and reading a live database that way can fail outright as
|
||||
"database disk image is malformed". Plain ro reads the WAL through its
|
||||
-shm; immutable is only the fallback for a database whose -shm cannot be
|
||||
read. The same law scripts/media-verify.py states.
|
||||
"""
|
||||
last = None
|
||||
for opt in ("mode=ro", "mode=ro&immutable=1"):
|
||||
try:
|
||||
return sqlite3.connect(f"file:{db}?{opt}", uri=True)
|
||||
except sqlite3.Error as e:
|
||||
last = e
|
||||
sys.exit(f"cannot open the Plex database read-only ({db}): {last}\n"
|
||||
"Plex runs rootless here: run this as the container's owner, never "
|
||||
"with sudo — rootless storage is per-user, so root sees nothing.")
|
||||
|
||||
|
||||
def enumerate_lib(root, out):
|
||||
"""One row per real file under root: size, extension, path."""
|
||||
rows = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames
|
||||
if not SKIP_DIR.search(os.path.join(dirpath, d))]
|
||||
for fn in filenames:
|
||||
p = os.path.join(dirpath, fn)
|
||||
if SKIP_DIR.search(p):
|
||||
continue
|
||||
try:
|
||||
st = os.lstat(p)
|
||||
except OSError:
|
||||
continue
|
||||
if not os.path.isfile(p) or os.path.islink(p):
|
||||
continue # links and sockets are not library content
|
||||
rows.append((st.st_size, os.path.splitext(fn)[1].lower(), p))
|
||||
with open(out, "w") as fh:
|
||||
for size, ext, p in rows:
|
||||
fh.write(f"{size}\t{ext}\t{p}\n")
|
||||
vid = [r for r in rows if r[1] in PROBE_EXT]
|
||||
print(f"files={len(rows)} bytes={sum(r[0] for r in rows)} "
|
||||
f"probes={len(vid)} probe_bytes={sum(r[0] for r in vid)} -> {out}")
|
||||
|
||||
|
||||
def probe_one(path):
|
||||
"""ffprobe header read -> the probed TSV row (never the whole file)."""
|
||||
try:
|
||||
os.nice(19) # a census must never outrank the media server
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
size = os.stat(path).st_size
|
||||
except OSError:
|
||||
return None
|
||||
cmd = ["ffprobe", "-v", "error", "-print_format", "json",
|
||||
"-show_format", "-show_streams", path]
|
||||
try:
|
||||
done = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
data = json.loads(done.stdout or "{}")
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError,
|
||||
json.JSONDecodeError, OSError):
|
||||
data = {}
|
||||
st = data.get("streams", [])
|
||||
fmt = data.get("format", {})
|
||||
v = next((s for s in st if s.get("codec_type") == "video"), None)
|
||||
aud = [s for s in st if s.get("codec_type") == "audio"]
|
||||
subs = [s for s in st if s.get("codec_type") == "subtitle"]
|
||||
fps = "-"
|
||||
if v and v.get("r_frame_rate"):
|
||||
try:
|
||||
n, d = (int(x) for x in v["r_frame_rate"].split("/", 1))
|
||||
fps = f"{round(n / d * 100) / 100:g}" if d else "0"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def num(x):
|
||||
try:
|
||||
return round(float(x))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
return [size, path, num(fmt.get("duration")),
|
||||
(v or {}).get("codec_name", "-"),
|
||||
f"{v['width']}x{v['height']}" if v else "-",
|
||||
fps, num(fmt.get("bit_rate")), (v or {}).get("pix_fmt", "-"),
|
||||
"+".join(f"{a.get('codec_name', '?')}:{a.get('language', '?')}" for a in aud),
|
||||
len(subs),
|
||||
sum(1 for a in aud if not re.search(
|
||||
r"default|forced", json.dumps(a.get("tags", {})), re.I)),
|
||||
fmt.get("format_name", "-") or "-"]
|
||||
|
||||
|
||||
def probe(enum, out, jobs):
|
||||
"""Probe every video-container row of the enum TSV, in parallel."""
|
||||
paths = []
|
||||
with open(enum) as fh:
|
||||
for line in fh:
|
||||
f = line.rstrip("\n").split("\t", 2)
|
||||
if len(f) == 3 and f[1] in PROBE_EXT:
|
||||
paths.append(f[2])
|
||||
print(f"probing {len(paths)} files with {jobs} workers", flush=True)
|
||||
done = unreadable = 0
|
||||
with open(out, "w") as fh, ThreadPoolExecutor(max_workers=jobs) as pool:
|
||||
for row in pool.map(probe_one, paths):
|
||||
if row is None:
|
||||
continue
|
||||
if row[3] == "-":
|
||||
unreadable += 1 # header unreadable: the row still gets written
|
||||
done += 1
|
||||
fh.write("\t".join(str(x) for x in row) + "\n")
|
||||
if done % 250 == 0:
|
||||
print(f" {done}/{len(paths)}", flush=True)
|
||||
print(f"probed={done} unreadable={unreadable} -> {out}")
|
||||
|
||||
|
||||
def plex(root, db, out):
|
||||
"""Plex's matched titles for files under root, from its DB, read-only."""
|
||||
con = open_plex_ro(db)
|
||||
with open(out, "w", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||
w.writerow(PLEX_COLS)
|
||||
n = 0
|
||||
for r in con.execute(PLEX_SQL, (root + "/%",)):
|
||||
w.writerow([str(x).replace("\t", " ").replace("\n", " ") for x in r])
|
||||
n += 1
|
||||
print(f"plex-matched={n} -> {out}")
|
||||
|
||||
|
||||
def jellyfin(root, db, container, out):
|
||||
"""The episodes Jellyfin has already numbered, for the library at root.
|
||||
|
||||
Jellyfin stores each path as the CONTAINER sees it (its quadlet binds the
|
||||
share at /data), so `container` is the in-container prefix that stands for
|
||||
root and every exported path is mapped back to the host before it is written
|
||||
— the planner reads host paths, because it plans moves on the array.
|
||||
|
||||
The numbering is what matters, not the name: a release group's file name says
|
||||
nothing about which episode a file is, and Jellyfin has already answered that
|
||||
question against a provider. Season/episode and the episode's own title come
|
||||
from the episode row; the series name, its year and its TMDb and Tvdb ids come
|
||||
from the Series row the episode climbs to, because those are the only place
|
||||
Jellyfin keeps them.
|
||||
"""
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
except sqlite3.Error as e:
|
||||
sys.exit(f"cannot open the Jellyfin database read-only ({db}): {e}\n"
|
||||
"It is a WAL database: copy it with its -wal and -shm beside it and"
|
||||
" point --db at the copy. Never sudo it — the data dir belongs to"
|
||||
" the container's owner, and rootless storage is per-user.")
|
||||
have = {r[1] for r in con.execute("pragma table_info(BaseItems)")}
|
||||
need = {"Id", "ParentId", "Type", "Path", "IndexNumber", "ParentIndexNumber",
|
||||
"ExtraType", "IsVirtualItem", "ProductionYear", "Data"}
|
||||
if not need <= have:
|
||||
sys.exit(f"{db} is not a Jellyfin library database (BaseItems has no"
|
||||
f" {','.join(sorted(need - have))})")
|
||||
node = {}
|
||||
for i, pid, typ, name, year, data in con.execute(
|
||||
"select Id, ParentId, Type, Name, ProductionYear, Data from BaseItems"
|
||||
" where Type in (?,?,?)", JF_TV):
|
||||
node[i] = (pid, typ, name, year, data)
|
||||
|
||||
# Jellyfin 12 keeps provider ids in their own table; older builds kept them in
|
||||
# the row's Data blob. Both shapes are read, because the tvdb id the series
|
||||
# naming depends on is wherever this build happened to write it.
|
||||
prov = {}
|
||||
if "BaseItemProviders" in {r[0] for r in con.execute(
|
||||
"select name from sqlite_master where type='table'")}:
|
||||
for iid, pid, val in con.execute(
|
||||
"select ItemId, ProviderId, ProviderValue from BaseItemProviders"
|
||||
" where ProviderId in ('Tmdb', 'Tvdb')"):
|
||||
prov[(iid, pid)] = val
|
||||
|
||||
def provider_of(item_id, want):
|
||||
got = prov.get((item_id, want))
|
||||
if got:
|
||||
return str(got)
|
||||
try:
|
||||
blob = json.loads(node[item_id][4] or "{}").get("ProviderIds") or {}
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return ""
|
||||
return str(blob.get(want, "") or "")
|
||||
|
||||
def series_of(item_id):
|
||||
"""Climb episode -> season -> series; empty when the chain is broken."""
|
||||
for _ in range(4):
|
||||
if item_id not in node:
|
||||
return "", "", "", ""
|
||||
pid, typ = node[item_id][0], node[item_id][1]
|
||||
if typ.endswith(".Series"):
|
||||
return (node[item_id][2] or "", node[item_id][3] or "",
|
||||
provider_of(item_id, "Tmdb"), provider_of(item_id, "Tvdb"))
|
||||
item_id = pid
|
||||
return "", "", "", ""
|
||||
|
||||
# The fleet's quadlets bind the share at /data, so the container's view of
|
||||
# this library is /data/<the library's own folder name> unless told otherwise.
|
||||
if not container:
|
||||
container = "/data/" + os.path.basename(os.path.normpath(root))
|
||||
prefix = container.rstrip("/") + "/"
|
||||
written = skipped = 0
|
||||
with open(out, "w", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||
w.writerow(JF_COLS)
|
||||
for iid, path, idx, pidx, extra, virt, epname in con.execute(
|
||||
JF_SQL, (JF_TV[0], prefix + "%")):
|
||||
if virt or not path or not path.startswith(prefix):
|
||||
skipped += 1 # a virtual item is a variant, not a file
|
||||
continue
|
||||
sname, syear, tmdb, tvdb = series_of(iid)
|
||||
w.writerow([root + "/" + path[len(prefix):],
|
||||
sname.replace("\t", " "), syear,
|
||||
"" if pidx is None else pidx,
|
||||
"" if idx is None else idx,
|
||||
(extra or "").replace("\t", " "), tmdb,
|
||||
(epname or "").replace("\t", " "), tvdb])
|
||||
written += 1
|
||||
print(f"episodes={written} virtual-or-blank={skipped} -> {out}")
|
||||
|
||||
|
||||
def music(root, db, container, out):
|
||||
"""The tracks Jellyfin has already tagged, for the library at root.
|
||||
|
||||
Jellyfin reads music metadata out of the file itself, so an Audio row IS the
|
||||
tag read, done once, by the vendor's own parser: its album-artist, album,
|
||||
year, disc and track columns are what the embedded tags say and its Name is
|
||||
the track title. That makes this export the whole authority for a music
|
||||
relayout — the path a ripper chose is not evidence of anything, and the
|
||||
folder Jellyfin put a track under is often a release pack, not an album.
|
||||
|
||||
Two gaps this export answers from the row rather than the tree. Many tracks
|
||||
sit under a folder that is not an album item at all (a disc folder, or a
|
||||
mixed one), so the album name and year fall back to the tags when the album
|
||||
row is absent. And an album's MusicBrainz ids are not in the row's own Data
|
||||
— the current schema leaves that empty for music — they are rows of
|
||||
BaseItemProviders keyed by the item's Id.
|
||||
"""
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
except sqlite3.Error as e:
|
||||
sys.exit(f"cannot open the Jellyfin database read-only ({db}): {e}\n"
|
||||
"It is a WAL database: copy it with its -wal and -shm beside it and"
|
||||
" point --db at the copy. Never sudo it — the data dir belongs to"
|
||||
" the container's owner, and rootless storage is per-user.")
|
||||
have = {r[1] for r in con.execute("pragma table_info(BaseItems)")}
|
||||
need = {"Id", "ParentId", "Type", "Path", "Name", "IndexNumber",
|
||||
"ParentIndexNumber", "ProductionYear", "Album", "AlbumArtists",
|
||||
"Artists"}
|
||||
if not need <= have:
|
||||
sys.exit(f"{db} is not a Jellyfin library database with music columns"
|
||||
f" (BaseItems has no {','.join(sorted(need - have))})")
|
||||
# Provider ids, best identity first: the release group is the album, a bare
|
||||
# release id is one reissue of it and never overrides a group.
|
||||
mb = {}
|
||||
if "BaseItemProviders" in {r[0] for r in con.execute(
|
||||
"select name from sqlite_master where type='table'")}:
|
||||
rank = {p: i for i, p in enumerate(MUSIC_PROVIDERS)}
|
||||
best = {}
|
||||
for iid, pid, val in con.execute(
|
||||
"select ItemId, ProviderId, ProviderValue from BaseItemProviders"
|
||||
" where ProviderId in (?, ?)", MUSIC_PROVIDERS):
|
||||
if iid not in best or rank[pid] < best[iid][0]:
|
||||
best[iid] = (rank[pid], val)
|
||||
mb = {k: v[1] for k, v in best.items()}
|
||||
|
||||
# The fleet's quadlets bind the share at /data, so the container's view of
|
||||
# this library is /data/<the library's own folder name> unless told otherwise.
|
||||
if not container:
|
||||
container = "/data/" + os.path.basename(os.path.normpath(root))
|
||||
prefix = container.rstrip("/") + "/"
|
||||
written = skipped = 0
|
||||
with open(out, "w", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||
w.writerow(MUSIC_COLS)
|
||||
for (path, aal, arts, tag_album, tag_year, disc, track, name, iid,
|
||||
alb_name, alb_year, alb_id) in con.execute(
|
||||
MUSIC_SQL, (JF_ALBUM, JF_AUDIO, prefix + "%")):
|
||||
if not path or not path.startswith(prefix):
|
||||
skipped += 1 # a row for a file outside this library
|
||||
continue
|
||||
# The album artist owns the folder; the track artist is the fallback
|
||||
# for the compilations whose album-artist tag was left empty.
|
||||
w.writerow([root + "/" + path[len(prefix):],
|
||||
((aal or "").strip() or (arts or "").strip()),
|
||||
(alb_name or tag_album or "").replace("\t", " "),
|
||||
alb_year or tag_year or "",
|
||||
"" if disc is None else disc,
|
||||
"" if track is None else track,
|
||||
(name or "").replace("\t", " "),
|
||||
mb.get(alb_id or "") or mb.get(iid or "") or "",
|
||||
(arts or "").replace("\t", " ")])
|
||||
written += 1
|
||||
print(f"tracks={written} outside-root-or-blank={skipped} -> {out}")
|
||||
|
||||
|
||||
def audiobooks(root, db, out):
|
||||
"""The chapters Plex matched inside the audiobook library, read-only.
|
||||
|
||||
Plex stores HOST paths here (unlike Jellyfin, which stores the container's
|
||||
view), so no prefix mapping is needed: the part's `file` is the path the
|
||||
operator has today. Rows for files outside `root` are skipped, because one
|
||||
Plex library DB carries every library on the box.
|
||||
|
||||
The chapter's `index` is exported as an empty field when Plex has none. That
|
||||
is not a gap to fill in here: three quarters of the books on this box contain
|
||||
at least one un-indexed chapter, so the relayout that reads this file must
|
||||
not renumber anything from it.
|
||||
"""
|
||||
con = open_plex_ro(db)
|
||||
tables = {r[0] for r in con.execute(
|
||||
"select name from sqlite_master where type='table'")}
|
||||
missing = {"media_parts", "media_items", "metadata_items"} - tables
|
||||
if missing:
|
||||
sys.exit(f"{db} is not a Plex library database (no "
|
||||
f"{', '.join(sorted(missing))})")
|
||||
written = skipped = 0
|
||||
with open(out, "w", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||
w.writerow(AB_COLS)
|
||||
for file, narrator, book, year, track, chapter in con.execute(
|
||||
AB_SQL, (root.rstrip("/") + "/%",)):
|
||||
if not file or not file.startswith(root.rstrip("/") + "/"):
|
||||
skipped += 1 # a row for a file outside this library
|
||||
continue
|
||||
w.writerow([file.replace("\t", " "), narrator.replace("\t", " "),
|
||||
book.replace("\t", " "), "" if not year else year,
|
||||
"" if track is None or int(track) < 0 else track,
|
||||
chapter.replace("\t", " ")])
|
||||
written += 1
|
||||
print(f"chapters={written} outside-root={skipped} -> {out}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
root = {"default": os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies"),
|
||||
"help": "library root"}
|
||||
e = sub.add_parser("enumerate", help="stat every file under the root")
|
||||
e.add_argument("--root", **root)
|
||||
e.add_argument("--out", default="/tmp/media-enum.tsv")
|
||||
p = sub.add_parser("probe", help="ffprobe every video container")
|
||||
p.add_argument("--enum", default="/tmp/media-enum.tsv")
|
||||
p.add_argument("--out", default="/tmp/media-probed.tsv")
|
||||
p.add_argument("--jobs", type=int, default=6)
|
||||
x = sub.add_parser("plex", help="export Plex's matched titles for the root")
|
||||
x.add_argument("--root", **root)
|
||||
x.add_argument("--db", default=PLEX_DB)
|
||||
x.add_argument("--out", default="/tmp/plex-movies.tsv")
|
||||
j = sub.add_parser("jellyfin",
|
||||
help="export the episodes Jellyfin has numbered for the root")
|
||||
j.add_argument("--root", default=os.environ.get("MEDIA_ROOT",
|
||||
"/mnt/entropy/TVShows"),
|
||||
help="TV library root on the host")
|
||||
j.add_argument("--db", default=JF_DB)
|
||||
j.add_argument("--container", default=None,
|
||||
help="the in-container prefix that stands for --root"
|
||||
" (default /data/<root's folder name>)")
|
||||
j.add_argument("--out", default="/tmp/jellyfin-tv.tsv")
|
||||
m = sub.add_parser("music",
|
||||
help="export the tracks Jellyfin has tagged for the root")
|
||||
m.add_argument("--root", default=os.environ.get("MEDIA_MUSIC_ROOT",
|
||||
"/mnt/entropy/Music"),
|
||||
help="music library root on the host")
|
||||
m.add_argument("--db", default=JF_DB)
|
||||
m.add_argument("--container", default=None,
|
||||
help="the in-container prefix that stands for --root"
|
||||
" (default /data/<root's folder name>)")
|
||||
m.add_argument("--out", default="/tmp/jellyfin-music.tsv")
|
||||
b = sub.add_parser("audiobooks",
|
||||
help="export the chapters Plex matched for the audiobook root")
|
||||
b.add_argument("--root", default=os.environ.get("MEDIA_AUDIOBOOKS_ROOT",
|
||||
"/mnt/entropy/Audiobooks"),
|
||||
help="audiobook library root on the host")
|
||||
b.add_argument("--db", default=PLEX_DB)
|
||||
b.add_argument("--out", default="/tmp/plex-audiobooks.tsv")
|
||||
a = ap.parse_args()
|
||||
if a.cmd == "enumerate":
|
||||
enumerate_lib(a.root, a.out)
|
||||
elif a.cmd == "probe":
|
||||
probe(a.enum, a.out, a.jobs)
|
||||
elif a.cmd == "plex":
|
||||
plex(a.root, a.db, a.out)
|
||||
elif a.cmd == "music":
|
||||
music(a.root, a.db, a.container, a.out)
|
||||
elif a.cmd == "audiobooks":
|
||||
audiobooks(a.root, a.db, a.out)
|
||||
else:
|
||||
jellyfin(a.root, a.db, a.container, a.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+445
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""media-music.py — a plan that re-files the music library into Jellyfin's own shape.
|
||||
|
||||
media-music.py --root DIR --enum TSV --rows TSV [--out TSV] [--exceptions FILE]
|
||||
|
||||
Jellyfin's music contract (https://jellyfin.org/docs/general/server/media/music/)
|
||||
is not the movie one. Jellyfin reads the metadata out of the file, so a folder is
|
||||
an ALBUM and nothing else, every disc of an album belongs in that one folder, the
|
||||
tags decide the track order, and a file name only matters when it carries a
|
||||
character known to break playback (`< > : " / \ | ? *`) or when a lyric file has
|
||||
to match the basename of the track it belongs to. The plan is therefore:
|
||||
|
||||
Music/<album artist>/<Album> (<year>)/NN - <Title>.<ext>
|
||||
... the same folder, D.NN - <Title> when the album has discs
|
||||
Music/Various Artists/... when its tracks name
|
||||
more than one artist
|
||||
|
||||
The authority is the tag row Jellyfin already read — `scripts/media-census.py
|
||||
music` — never the release-pack folder a ripper made. A file with no row is not
|
||||
guessed at: it parks under `<root>/_unsorted` with the relative path it has today.
|
||||
|
||||
The rules below exist because of what a downloaded discography pack actually
|
||||
contains:
|
||||
|
||||
* A cuesheet beside exactly one audio file means the album is one image with a
|
||||
track list inside it. That file keeps its own name: numbering it `01` would
|
||||
claim it is one track, and Jellyfin shows one track either way.
|
||||
* Sidecars — images, cuesheets, EAC logs, checksums, booklets, playlists —
|
||||
follow the album the audio in their own folder belongs to. A folder holding
|
||||
two albums gives a sidecar no single owner, so it parks instead of being
|
||||
guessed into one.
|
||||
* A video container (.mkv, .vob, .ifo, …) inside a music library is not music.
|
||||
Renaming it to .mka/.m4a would tell Jellyfin to index video as audio, so it
|
||||
parks for a human decision.
|
||||
* Playlists move with their album and their CONTENTS are never rewritten: the
|
||||
paths inside an .m3u go stale. Editing a file the operator owns to keep a
|
||||
convenience file tidy is not this tool's job.
|
||||
|
||||
Nothing is deleted and nothing is overwritten. The output is a list of rename(2)
|
||||
intents for `scripts/media-apply.py`, whose preflight refuses any target that is
|
||||
already claimed and whose journal is the rollback.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
# What Jellyfin will index as music. .mp4/.mkv/.webm are deliberately NOT here:
|
||||
# the vendor doc says an audio-only stream in those containers is never read as
|
||||
# music, and this tool renames for a reason it can prove, not to guess at one.
|
||||
AUDIO_EXT = {".flac", ".mp3", ".m4a", ".m4b", ".ogg", ".oga", ".opus", ".wav",
|
||||
".wma", ".aiff", ".aif", ".ape", ".dsf", ".dff", ".wv", ".mka",
|
||||
".mpc", ".aac"}
|
||||
VIDEO_EXT = {".mkv", ".vob", ".ifo", ".bup", ".mpg", ".mpeg", ".avi", ".mov",
|
||||
".ts", ".m2ts", ".wmv", ".webm", ".mp4", ".m4v", ".iso", ".img",
|
||||
".bdmv", ".divx", ".ogm"}
|
||||
# A lyric has to carry the basename of its track, so it is named after the audio
|
||||
# file and not after itself. .txt counts only when its stem matches an audio stem:
|
||||
# a loose .txt in an album folder is a note.
|
||||
LYRIC_EXT = {".lrc", ".elrc", ".txt"}
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"}
|
||||
# The album-artist strings that mean "compilation", so such an album is not filed
|
||||
# under one artist who does not own it.
|
||||
VARIOUS = {"various", "various artists", "unknown", "unknown artist", "unknow"}
|
||||
# The characters Jellyfin documents as breaking playback. They are dropped, not
|
||||
# substituted: a replacement character is a guess at what the title meant.
|
||||
BAD_CHARS = set('<>:"/\\|?*')
|
||||
TRASH_DIR = re.compile(r"(^|/)(\.AGENTTRASH|\.thumbnails|\.Trash-\d+|@eaDir"
|
||||
r"|lost\+found)(/|$)")
|
||||
|
||||
ARGS = None
|
||||
HELD = {} # path -> (bytes, ext)
|
||||
ROWS = {} # path -> the tag row
|
||||
PLACED = {} # audio path -> the destination the plan gave it
|
||||
CLAIMED = set() # every destination path the plan has handed out
|
||||
MOVES, KEEPS = [], []
|
||||
PARKED = defaultdict(list) # reason -> [path]
|
||||
NOTES = defaultdict(list) # section -> [line]
|
||||
CUE_DIRS = set() # source dirs holding a .cue
|
||||
AUDIO_BY_DIR = defaultdict(set) # source dir -> audio files a row names
|
||||
CHILD_OF = defaultdict(set) # dir -> the dirs directly below it holding audio
|
||||
FOLDERS_SEEN = {} # (dir, depth) -> album folders placed below it
|
||||
SUBTREE_DEPTH = 2 # how far below a folder its audio may sit
|
||||
ALBUMS = {} # (artist, album, year) -> the album group
|
||||
|
||||
AMBIGUOUS = object()
|
||||
|
||||
|
||||
def dominant(values):
|
||||
"""The spelling the tags mostly agree on: the most frequent value, ties broken
|
||||
by sort so the answer never depends on the order rows arrived in."""
|
||||
counts = Counter(values)
|
||||
return min(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0]
|
||||
|
||||
|
||||
def rel(path):
|
||||
"""The path as seen from the library root."""
|
||||
return os.path.relpath(path, ARGS.root)
|
||||
|
||||
|
||||
def safe(name):
|
||||
"""A name every client can carry: no reserved character, nothing
|
||||
unprintable, no trailing dot or space, and short enough to rename."""
|
||||
out = "".join(c for c in (name or "")
|
||||
if c not in BAD_CHARS and (c.isprintable() or c == " "))
|
||||
out = re.sub(r"\s+", " ", out).strip().rstrip(". ")
|
||||
while out and len(out.encode("utf-8", "ignore")) > 200:
|
||||
out = out[:-4].rstrip()
|
||||
return out
|
||||
|
||||
|
||||
def assign(src, folder, name, ext, kind, note):
|
||||
"""Book one file into `folder` under `name`, renamed if that exact destination
|
||||
is already taken — by another file in the plan, or by a file standing there
|
||||
today. Two files claiming one path is the fault that costs a re-run of a 90k
|
||||
plan, and so is a plan that only works if the renames happen in the right
|
||||
order: the executor refuses any target that is occupied, so an occupied path
|
||||
is never a destination, even when its occupant is scheduled to leave.
|
||||
A file already standing where the contract puts it is a keep, not a move."""
|
||||
stem = safe(name) or "file"
|
||||
dst = os.path.join(folder, stem + ext)
|
||||
if dst == src:
|
||||
CLAIMED.add(dst.lower()) # it is standing on its own target
|
||||
KEEPS.append((src, dst, kind))
|
||||
return dst
|
||||
# A file already in this folder under this name — or under the numbered
|
||||
# variant the collision rule gives it — keeps the name it has. Renumbering it
|
||||
# every run would turn the plan into a shuffle instead of a fixed point.
|
||||
base = os.path.splitext(os.path.basename(src))[0]
|
||||
if os.path.dirname(src) == folder and (base == stem
|
||||
or base.startswith(stem + " (")):
|
||||
CLAIMED.add(src.lower())
|
||||
KEEPS.append((src, src, kind))
|
||||
return src
|
||||
n = 1
|
||||
while dst.lower() in CLAIMED:
|
||||
n += 1
|
||||
dst = os.path.join(folder, f"{stem} ({n}){ext}")
|
||||
if n > 1:
|
||||
NOTES[note].append(rel(dst))
|
||||
CLAIMED.add(dst.lower())
|
||||
MOVES.append((src, dst, kind))
|
||||
return dst
|
||||
|
||||
|
||||
def park(path, reason):
|
||||
"""Hold a file where it is, under the park folder, keeping its relative path."""
|
||||
PARKED[reason].append(path)
|
||||
dst = os.path.join(ARGS.park_dir, rel(path))
|
||||
if dst != path:
|
||||
CLAIMED.add(dst.lower())
|
||||
MOVES.append((path, dst, "park"))
|
||||
|
||||
|
||||
def under_park(path):
|
||||
"""True for a file already parked: that pile is for a human to review, and
|
||||
re-parking it every run would only push it deeper."""
|
||||
return path.startswith(ARGS.park_dir + os.sep)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- albums
|
||||
def group_albums():
|
||||
"""Every tagged track into one group per (album-artist tag, album, year).
|
||||
|
||||
The key is the identity the tags assert, not the folder the file sits in: one
|
||||
album arrives as three discs in three folders, and two copies of an album in
|
||||
one pack are one album to Jellyfin, whose album item is that name, artist and
|
||||
year. The artist in the key is the ALBUM-artist tag and never the performer,
|
||||
because a compilation is one album whose tracks name many performers — keying
|
||||
on the performer would file one album once per artist.
|
||||
"""
|
||||
for path, r in sorted(ROWS.items()):
|
||||
if path not in HELD or under_park(path):
|
||||
continue
|
||||
d = os.path.dirname(path)
|
||||
AUDIO_BY_DIR[d].add(path)
|
||||
CHILD_OF[os.path.dirname(d)].add(d) # so a cover above CD1/CD2 sees both
|
||||
tag = (r["artist"] or "").strip()
|
||||
album = (r["album"] or "").strip()
|
||||
performer = (r["performer"] or "").strip()
|
||||
if not album:
|
||||
park(path, "a track no album names")
|
||||
continue
|
||||
if not tag and not performer:
|
||||
park(path, "a track no artist names")
|
||||
continue
|
||||
key = (tag.casefold(), album.casefold(), (r["year"] or "").strip())
|
||||
g = ALBUMS.setdefault(key, {"tags": [], "albums": [], "year": key[2],
|
||||
"members": [], "performers": set(),
|
||||
"mb": set()})
|
||||
g["members"].append(path)
|
||||
g["tags"].append(tag)
|
||||
g["albums"].append(album)
|
||||
g["performers"].add(performer or tag)
|
||||
if r.get("mb"):
|
||||
g["mb"].add(r["mb"])
|
||||
|
||||
|
||||
def folder_artist(g):
|
||||
"""Whose shelf the album sits on. The album-artist tag owns it when it names
|
||||
one; when the tags name only performers, a single performer names the album
|
||||
and several make it a compilation, which belongs to no one of them. The key is
|
||||
case-folded, so the spelling that reaches the folder is the one the tracks
|
||||
mostly carry — see dominant()."""
|
||||
tags = [t for t in g["tags"] if t]
|
||||
if tags:
|
||||
name = dominant(tags)
|
||||
return "Various Artists" if name.casefold() in VARIOUS else name
|
||||
if len({p.casefold() for p in g["performers"]}) == 1:
|
||||
return dominant(g["performers"])
|
||||
return "Various Artists"
|
||||
|
||||
|
||||
def album_folder(g):
|
||||
name = safe(dominant(g["albums"])) or "Untitled"
|
||||
return os.path.join(ARGS.root, safe(folder_artist(g)) or "Unknown Artist",
|
||||
f"{name} ({g['year']})" if g["year"] else name)
|
||||
|
||||
|
||||
def is_cue_image(path):
|
||||
"""A folder holding this one audio file and a cuesheet is an album image."""
|
||||
d = os.path.dirname(path)
|
||||
return len(AUDIO_BY_DIR.get(d, ())) == 1 and d in CUE_DIRS
|
||||
|
||||
|
||||
def number_of(value, width):
|
||||
return f"{int(value):0{width}d}" if str(value or "").isdigit() else ""
|
||||
|
||||
|
||||
def place_albums():
|
||||
for key in sorted(ALBUMS):
|
||||
g = ALBUMS[key]
|
||||
folder = album_folder(g)
|
||||
discs = {g0["disc"] for g0 in (ROWS[p] for p in g["members"]) if g0["disc"]}
|
||||
tracks = [int(ROWS[p]["track"]) for p in g["members"]
|
||||
if str(ROWS[p]["track"] or "").isdigit()]
|
||||
# More than one disc, or a disc numbered past the first: the track number
|
||||
# alone cannot name a file uniquely, so the disc goes in front of it.
|
||||
multi = len(discs) > 1 or any(str(d).isdigit() and int(d) > 1 for d in discs)
|
||||
width = 3 if multi or (max(tracks, default=0) > 99) else 2
|
||||
for path in sorted(g["members"]):
|
||||
r = ROWS[path]
|
||||
ext = HELD[path][1]
|
||||
base = os.path.splitext(os.path.basename(path))[0]
|
||||
if is_cue_image(path):
|
||||
name = safe(base) or "image"
|
||||
else:
|
||||
title = safe(r["title"] or base) or "untitled"
|
||||
num = number_of(r["track"], 2 if multi else width)
|
||||
if multi and num:
|
||||
num = f"{r['disc'] or '1'}.{num}"
|
||||
name = f"{num} - {title}" if num else title
|
||||
PLACED[path] = assign(path, folder, name, ext, "track",
|
||||
"two tracks claimed one name inside one album")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- sidecars
|
||||
def folders_below(d, depth):
|
||||
"""The album folders the audio in `d`, or within `depth` directories below it,
|
||||
was placed in. Memoised, because every loose file in a big discography folder
|
||||
asks the same question about it."""
|
||||
if (d, depth) not in FOLDERS_SEEN:
|
||||
seen = {os.path.dirname(PLACED[a]) for a in AUDIO_BY_DIR.get(d, ())
|
||||
if a in PLACED}
|
||||
if depth:
|
||||
for child in CHILD_OF.get(d, ()):
|
||||
seen |= folders_below(child, depth - 1)
|
||||
FOLDERS_SEEN[(d, depth)] = seen
|
||||
return FOLDERS_SEEN[(d, depth)]
|
||||
|
||||
|
||||
def owner_folder(d):
|
||||
"""The album folder that owns this directory — the first place up the tree
|
||||
where audio sits, in the directory itself or within SUBTREE_DEPTH levels below
|
||||
it, which is how a box with its discs in CD1/CD2 owns the cover lying above
|
||||
them. None when no audio is near enough to say, AMBIGUOUS when the audio there
|
||||
belongs to more than one album."""
|
||||
while d.startswith(ARGS.root + os.sep) and d != ARGS.root:
|
||||
folders = folders_below(d, SUBTREE_DEPTH)
|
||||
if folders:
|
||||
return next(iter(folders)) if len(folders) == 1 else AMBIGUOUS
|
||||
d = os.path.dirname(d)
|
||||
return None
|
||||
|
||||
|
||||
def place_sidecars():
|
||||
"""Everything that is not a tagged track: lyrics, images, cuesheets, logs,
|
||||
booklets, playlists — and the clutter that is none of those."""
|
||||
# The lyric rule needs the audio's stem as it is named TODAY, before the plan
|
||||
# renames it, because that is what the sidecar's own name was copied from.
|
||||
stem = {(d, os.path.splitext(os.path.basename(a))[0]): a
|
||||
for d, audios in AUDIO_BY_DIR.items() for a in audios}
|
||||
for path, (_size, ext) in sorted(HELD.items()):
|
||||
if path in ROWS or under_park(path) or TRASH_DIR.search(path):
|
||||
continue
|
||||
d, base = os.path.dirname(path), os.path.splitext(os.path.basename(path))[0]
|
||||
if ext in VIDEO_EXT:
|
||||
park(path, "a video container inside the music library: Jellyfin will "
|
||||
"not read it as music, and renaming it would claim it is")
|
||||
elif ext in AUDIO_EXT:
|
||||
park(path, "an audio file no provider row names — its tags are not in "
|
||||
"the library Jellyfin has matched")
|
||||
elif ext in (LYRIC_EXT | IMAGE_EXT) and (d, base) in stem:
|
||||
# A lyric, or a picture named after its track: it follows that track
|
||||
# and takes the name the track now has, or Jellyfin cannot find it.
|
||||
audio = stem[(d, base)]
|
||||
dst = PLACED.get(audio)
|
||||
if dst:
|
||||
folder, name = os.path.dirname(dst), os.path.splitext(
|
||||
os.path.basename(dst))[0]
|
||||
assign(path, folder, name, ext, "lyric",
|
||||
"a sidecar whose name collided with another in its album")
|
||||
else:
|
||||
park(path, "a sidecar of a track the plan could not place")
|
||||
else:
|
||||
owner = owner_folder(d)
|
||||
if owner is AMBIGUOUS:
|
||||
park(path, "a sidecar whose folder holds more than one album, so "
|
||||
"nothing says which one it belongs to")
|
||||
elif owner:
|
||||
assign(path, owner, base, ext,
|
||||
"playlist" if ext in (".m3u", ".m3u8") else "sidecar",
|
||||
"one album folder would have carried two files of the same "
|
||||
"sidecar name")
|
||||
else:
|
||||
park(path, "not music, or music with nothing beside it to belong to")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- outputs
|
||||
def write_plan():
|
||||
"""mkdir rows for every destination folder and its whole chain, then the moves.
|
||||
Parents first: the executor creates directories in the order they are listed."""
|
||||
chain = set()
|
||||
for _s, dst, _k in MOVES:
|
||||
d = os.path.dirname(dst)
|
||||
while d and d != ARGS.root and d != os.path.dirname(d):
|
||||
chain.add(d)
|
||||
d = os.path.dirname(d)
|
||||
dirs = sorted(chain, key=lambda p: (p.count("/"), p))
|
||||
with open(ARGS.out, "w") as fh:
|
||||
fh.write("action\tsrc\tdst\tkind\n")
|
||||
for d in dirs:
|
||||
fh.write(f"mkdir\t\t{d}\tdir\n")
|
||||
for src, dst, kind in sorted(MOVES, key=lambda m: (m[1], m[0])):
|
||||
fh.write(f"move\t{src}\t{dst}\t{kind}\n")
|
||||
for src, dst, kind in sorted(KEEPS, key=lambda m: (m[1], m[0])):
|
||||
fh.write(f"keep\t{src}\t{dst}\t{kind}\n")
|
||||
parked = [m for m in MOVES if m[2] == "park"]
|
||||
sized = sum(HELD[s][0] for s, _d, _k in MOVES if s in HELD)
|
||||
print(f"plan: {len(MOVES) - len(parked)} moves, {len(parked)} parked"
|
||||
f" ({sized / 2**40:.2f} TiB touched), {len(KEEPS)} already placed,"
|
||||
f" {len(dirs)} folders -> {ARGS.out}")
|
||||
|
||||
|
||||
def write_exceptions():
|
||||
with open(ARGS.exceptions, "w") as fh:
|
||||
fh.write(f"Music relayout exceptions for {ARGS.root}\n\n")
|
||||
fh.write(f"Everything below is parked under {ARGS.park_dir}, keeping the\n"
|
||||
"relative path it has today. Nothing is deleted and nothing is\n"
|
||||
"overwritten: a parked file is one rename from wherever the plan is\n"
|
||||
"pointed next.\n\n")
|
||||
for reason in sorted(PARKED, key=lambda r: -len(PARKED[r])):
|
||||
rs = PARKED[reason]
|
||||
fh.write(f"{len(rs):>6} files {reason}\n")
|
||||
for p in sorted(rs, key=rel)[:400]:
|
||||
fh.write(f" {rel(p)}\n")
|
||||
if len(rs) > 400:
|
||||
fh.write(f" … and {len(rs) - 400} more\n")
|
||||
fh.write("\n")
|
||||
for section in sorted(NOTES, key=lambda s: -len(NOTES[s])):
|
||||
lines = sorted(set(NOTES[section]))
|
||||
fh.write(f"{len(lines):>6} {section}\n")
|
||||
for line in lines[:200]:
|
||||
fh.write(f" {line}\n")
|
||||
if len(lines) > 200:
|
||||
fh.write(f" … and {len(lines) - 200} more\n")
|
||||
fh.write("\n")
|
||||
no_year = sorted(f"{folder_artist(g)}/{dominant(g['albums'])}"
|
||||
for g in ALBUMS.values() if not g["year"])
|
||||
if no_year:
|
||||
fh.write(f"{len(no_year):>6} albums carry no year in any tag, so their folders\n"
|
||||
" are named without one; Jellyfin still matches them.\n")
|
||||
fh.write(" " + ", ".join(no_year[:300]) + "\n\n")
|
||||
clash = sorted(f"{folder_artist(g)}/{dominant(g['albums'])}"
|
||||
for g in ALBUMS.values() if len(g["mb"]) > 1)
|
||||
if clash:
|
||||
fh.write(f"{len(clash):>6} albums put one folder name on more than one\n"
|
||||
" MusicBrainz release group — different releases, one name.\n"
|
||||
" They share the folder Jellyfin's own album item names.\n")
|
||||
fh.write(" " + ", ".join(clash[:200]) + "\n\n")
|
||||
fh.write(" Playlists move with their album and their contents are never\n"
|
||||
" rewritten: the paths inside an .m3u go stale.\n")
|
||||
print(f"exceptions -> {ARGS.exceptions}")
|
||||
|
||||
|
||||
def main():
|
||||
global ARGS
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--root", default=os.environ.get("MEDIA_MUSIC_ROOT",
|
||||
"/mnt/entropy/Music"))
|
||||
ap.add_argument("--enum", default="/tmp/music-enum.tsv",
|
||||
help="the enumerate TSV (bytes, ext, path)")
|
||||
ap.add_argument("--rows", default="/tmp/jellyfin-music.tsv",
|
||||
help="the tag rows scripts/media-census.py music wrote")
|
||||
ap.add_argument("--out", default="/tmp/music-plan.tsv")
|
||||
ap.add_argument("--exceptions", default="/tmp/music-exceptions.txt")
|
||||
ap.add_argument("--park", default="_unsorted",
|
||||
help="the folder inside the root that holds what cannot be filed")
|
||||
ARGS = ap.parse_args()
|
||||
ARGS.root = os.path.normpath(ARGS.root)
|
||||
ARGS.park_dir = os.path.join(ARGS.root, ARGS.park)
|
||||
read_inputs()
|
||||
# Every path a file occupies today is claimed before a single destination is
|
||||
# handed out: the executor refuses an occupied target, so a plan that aimed at
|
||||
# one would only fail after the renames that were supposed to clear it.
|
||||
CLAIMED.update(p.lower() for p in HELD)
|
||||
for path, (_size, ext) in HELD.items():
|
||||
if ext == ".cue":
|
||||
CUE_DIRS.add(os.path.dirname(path))
|
||||
group_albums()
|
||||
place_albums()
|
||||
place_sidecars()
|
||||
write_plan()
|
||||
write_exceptions()
|
||||
|
||||
|
||||
def read_inputs():
|
||||
with open(ARGS.enum) as fh:
|
||||
for line in fh:
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) >= 3:
|
||||
HELD[parts[2]] = (int(parts[0]), parts[1].lower())
|
||||
with open(ARGS.rows, newline="") as fh:
|
||||
for row in csv.DictReader(fh, delimiter="\t"):
|
||||
if row.get("file"):
|
||||
ROWS[row["file"]] = row
|
||||
gone = [p for p in ROWS if p not in HELD]
|
||||
if gone:
|
||||
NOTES["a tag row whose file the census no longer sees"].extend(gone)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+881
@@ -0,0 +1,881 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Plan a Jellyfin-format relayout of a movie library. DRY RUN ONLY: nothing moves.
|
||||
|
||||
media-plan.py [--root DIR] [--enum TSV] [--probed TSV] [--plex TSV]
|
||||
[--out TSV] [--exceptions TXT]
|
||||
|
||||
Inputs are the TSVs scripts/media-census.py exports, plus the optional Plex
|
||||
metadata-cache export whose clean titles outrank the filename parser; the title
|
||||
authority for everything Plex never matched is scripts/media-titles.py beside this
|
||||
file. No media file is opened, so planning the whole library costs seconds.
|
||||
|
||||
PLAN is a TSV the executor (scripts/media-apply.py) replays verbatim —
|
||||
`action<TAB>src<TAB>dst<TAB>kind` with action in mkdir | move | keep, ordered so
|
||||
parents exist before children. EXCEPTIONS is the human review list: every file the
|
||||
plan could not name a film for, every collision it refused, the READMEs the
|
||||
executor must write into the piles it created.
|
||||
|
||||
Target contract (jellyfin.org/docs/general/server/media/movies):
|
||||
* one folder per movie, named Title (year)
|
||||
* the video inside is named exactly like the folder
|
||||
* extra copies of one film are versions: "<base> - <label>.ext"; the base must
|
||||
match the folder name character-for-character or Jellyfin splits them apart
|
||||
* extras live in canonical subfolders (trailers, featurettes, deleted scenes,
|
||||
interviews, samples, shorts, clips, other, extras, backdrops, theme-music)
|
||||
* sidecars <base>.<lang>.<flag>.<ext>; images <base>-<poster|backdrop|...>
|
||||
* <>:"/\|?* break the matcher, so they never survive here
|
||||
* VIDEO_TS/BDMV folders support neither multiple versions nor parts
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import csv
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
|
||||
# Inputs are the census TSVs scripts/media-census.py writes; the engine beside
|
||||
# this file is the title authority. Nothing here opens a media file.
|
||||
def parse_args():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--root", default=os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies"),
|
||||
help="library root the plan moves files inside")
|
||||
ap.add_argument("--enum", default="/tmp/media-enum.tsv", help="file census TSV")
|
||||
ap.add_argument("--probed", default="/tmp/media-probed.tsv", help="ffprobe census TSV")
|
||||
ap.add_argument("--plex", default="/tmp/plex-movies.tsv",
|
||||
help="Plex metadata export (clean titles); absent = parser only")
|
||||
ap.add_argument("--out", default="/tmp/jf-plan.tsv")
|
||||
ap.add_argument("--exceptions", default="/tmp/jf-exceptions.txt")
|
||||
return ap.parse_args()
|
||||
|
||||
|
||||
ARGS = parse_args()
|
||||
ROOT = ARGS.root
|
||||
ENUM, PROBE, PLEX = ARGS.enum, ARGS.probed, ARGS.plex
|
||||
PLAN, EXCEPTS = ARGS.out, ARGS.exceptions
|
||||
ENGINE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media-titles.py")
|
||||
|
||||
spec = importlib.util.spec_from_file_location("eng", ENGINE)
|
||||
eng = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(eng)
|
||||
|
||||
VIDEO_EXT = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts", ".m2ts", ".mpg",
|
||||
".mpeg", ".webm", ".wmv", ".divx", ".ogm", ".flv", ".vob", ".iso",
|
||||
".img", ".mpv", ".3gp", ".rm", ".rmvb", ".m2v", ".mpv"}
|
||||
DISC_DIRS = {"BDMV", "VIDEO_TS", "CERTIFICATE", "AACS", "MLPI", "CLIPINF"}
|
||||
SUB_EXT = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
|
||||
IMG_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn"}
|
||||
AUDIO_DIR_RE = re.compile(r"soundtrack|score|\bost\b|music|song|album", re.I)
|
||||
SUB_KEEP_RE = re.compile(r"^(?!.*\d)(?!.*[._])(?!.*\[)(?!.*\()([A-Za-z][A-Za-z -]{1,24})$")
|
||||
MUSIC_TOP_RE = re.compile(r"(?i)soundtrack|score|\bost\b|music|album|song")
|
||||
SUB_DIR_OK = re.compile(r"(?i)^$|subs?|subtitles?|srt|custom ?subs?(\[.*\])?"
|
||||
r"|(additional|extra) languages|languages|resources")
|
||||
AUDIO_DIR_OK = re.compile(r"(?i)^$|audio(s)?|extra audio|additional audio"
|
||||
r"|(additional|extra) languages|commentar(y|ies)"
|
||||
r"|audio commentary|audio tracks?")
|
||||
|
||||
|
||||
AUDIO_EXT = {".mp3", ".flac", ".m4a", ".aac", ".dts", ".wav", ".ac3", ".mka",
|
||||
".opus", ".ogg", ".wma"}
|
||||
NFO_EXT = {".nfo"}
|
||||
IMG_PRIMARY = {"poster", "folder", "cover", "default", "movie", "show"}
|
||||
IMG_OTHER = {"backdrop": "backdrop", "fanart": "backdrop", "background": "backdrop",
|
||||
"banner": "banner", "logo": "logo", "clearlogo": "logo",
|
||||
"thumb": "thumb", "landscape": "thumb", "art": "backdrop",
|
||||
"clearart": "backdrop", "jacket": "poster", "cdart": "poster",
|
||||
"discart": "poster", "disc": "poster"}
|
||||
RES_RE = re.compile(r"\b(2160|4320|4k|1080|720|576|480|360|240)(i|p)\b", re.I)
|
||||
# The characters Jellyfin's matcher cannot carry in a file or folder name. "/" is
|
||||
# one of them and it is the dangerous one: Plex titles KEEP it ("V/H/S/2", and this
|
||||
# library holds six of those films), so leaving it in a name turns ONE movie folder
|
||||
# into a nested tree — Movies/V/H/S/2 (2013)/… — and Jellyfin then sees four
|
||||
# folders, none of them a movie.
|
||||
BAD_CHARS = re.compile(r'[<>:"/|?*]')
|
||||
PART_RE = re.compile(r"(?:^|[\s._-])(cd|dvd|part|pt|disc|disk|disco)"
|
||||
r"[\s._-]?([1-9abcd])(?:$|[\s._-])", re.I)
|
||||
LANGS = {"en", "eng", "it", "ita", "ja", "jpn", "de", "deu", "fr", "fra", "es",
|
||||
"spa", "pt", "por", "ru", "rus", "zh", "chi", "ko", "kor", "sv", "no",
|
||||
"da", "nl", "pl", "cs", "hu", "fi", "tr", "el", "he", "ar", "uk", "ro",
|
||||
"bg", "hr", "sr", "sk", "et", "lv", "lt", "id", "th", "vi", "fa", "la"}
|
||||
FLAGS = {"default", "forced", "foreign", "sdh", "cc"}
|
||||
NOISE = {"sub", "subs", "subtitle", "subtitles", "srt", "ass", "ext", "external",
|
||||
"the", "a", "an", "of", "and", "in", "on", "to", "for", "with", "ita",
|
||||
"eng", "english", "italian", "dual", "audio", "forced", "default"}
|
||||
# Canonical title where the library only ever carries a localised name.
|
||||
OVERRIDES = {"28 giorni dopo": "28 Days Later",
|
||||
"28 settimane dopo": "28 Weeks Later"}
|
||||
|
||||
rows = []
|
||||
for line in open(ENUM):
|
||||
size, ext, path = line.rstrip("\n").split("\t")
|
||||
rows.append({"bytes": int(size), "ext": ext.lower(), "path": path})
|
||||
ext_by = {r["path"]: r["ext"] for r in rows}
|
||||
size_by = {r["path"]: r["bytes"] for r in rows}
|
||||
|
||||
|
||||
def rel(p):
|
||||
return p[len(ROOT) + 1:] if p.startswith(ROOT + "/") else p
|
||||
|
||||
|
||||
def parts_of(p):
|
||||
return [q for q in rel(p).split("/") if q]
|
||||
|
||||
|
||||
def is_qb(p):
|
||||
return ".!q" in p.lower()
|
||||
|
||||
|
||||
def disc_root(p, fold=True):
|
||||
"""Absolute root of the disc tree containing p (the MOVIE folder that holds
|
||||
BDMV/ or VIDEO_TS/), else None. `fold` also folds bookkeeping dirs that sit
|
||||
beside the tree (CERTIFICATE/, AACS/) into the same movie folder."""
|
||||
ps = parts_of(p)
|
||||
kinds = DISC_DIRS if fold else {"BDMV", "VIDEO_TS"}
|
||||
for i, q in enumerate(ps):
|
||||
if q.upper() in kinds:
|
||||
return ROOT + "/" + "/".join(ps[:i])
|
||||
return None
|
||||
|
||||
|
||||
def row_name(r):
|
||||
"""The name a disc-tree entry should be identified by: the engine stores the
|
||||
disc's own folder for it, because "BDMV/index.bdmv" is not a title."""
|
||||
return r.get("name_src") or r["path"]
|
||||
|
||||
|
||||
def sanitize(name):
|
||||
name = BAD_CHARS.sub("-", name).replace("\\", "-")
|
||||
name = re.sub(r"\s+", " ", name).strip().strip(". ").strip()
|
||||
return (name[:180] or "untitled")
|
||||
|
||||
|
||||
def ok_year(y):
|
||||
"""True for a year a real film could carry. Plex stores 0 for a file it indexed
|
||||
but never MATCHED, and "0" is a digit string: trusting it named 67 movie
|
||||
folders "Title (0)" — Jellyfin items with no identity, no year and no poster."""
|
||||
return bool(re.fullmatch(r"(18|19|20)\d\d", y or "")) and 1870 <= int(y) <= 2035
|
||||
|
||||
|
||||
WORD_SPLIT = re.compile(r"[^0-9a-z]+")
|
||||
|
||||
|
||||
def words_of(text):
|
||||
"""The matching words of a name — or of a whole path, which is how callers
|
||||
pass a file together with the folders it sits in.
|
||||
|
||||
Split on everything that is not a letter or a digit: eng.norm does not touch
|
||||
"/", so feeding it "…/Psycho (1960)/Extras" left glued tokens like
|
||||
"(1960)/extras" and "(1954)" that no other name could ever agree with, and a
|
||||
film could not be found by the folder that holds it — which is how one box
|
||||
set's featurettes ended up in another film's folder. Both sides of every
|
||||
comparison below go through here. A bare number counts only when it could be
|
||||
a release year, so release junk ("1080", "2160", "2", "7") never poses as
|
||||
agreement between two names.
|
||||
"""
|
||||
out = set()
|
||||
for w in WORD_SPLIT.split(eng.norm(text)):
|
||||
if not w or w in eng.STOP:
|
||||
continue
|
||||
if w.isdigit() and not 1870 <= int(w) <= 2100:
|
||||
continue
|
||||
out.add(w)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Plex titles
|
||||
# Plex has matched a large part of the library's media files to real titles
|
||||
# ("Alien", not "Alien.Directors.Cut.1979.1080p.BRrip.x265…"). Where the cache
|
||||
# holds a MATCH, its title wins; the filename parser carries everything else
|
||||
# (extras, disc trees, TV, and whatever Plex never matched).
|
||||
def plex_titles():
|
||||
if not os.path.exists(PLEX):
|
||||
return {}
|
||||
out = {}
|
||||
with open(PLEX, newline="") as fh:
|
||||
for r in csv.DictReader(fh, delimiter="\t"):
|
||||
t, y = r["leaf_title"].strip(), r["leaf_year"].strip()
|
||||
# Only a match is an authority, and a match carries a year: an
|
||||
# unmatched row echoes the filename, which the parser reads better
|
||||
# than a copied release name. The junk filter stays for the matches
|
||||
# whose title still carries the release.
|
||||
if t and ok_year(y) and not re.search(
|
||||
r"2160p|1080p|x26[45]|hevc|bluray|web-?dl", t.lower()):
|
||||
out[r["file"]] = (t, y)
|
||||
return out
|
||||
|
||||
|
||||
PLEX_T = plex_titles()
|
||||
|
||||
# ------------------------------------------------------------- classification
|
||||
erows = eng.load(PROBE)
|
||||
for r in erows:
|
||||
for k, v in OVERRIDES.items():
|
||||
if k in r["title"]:
|
||||
r["title"] = v
|
||||
r["key"] = eng.key_of(r["title"])
|
||||
r["base"] = eng.base_words(r["title"], r["key"])
|
||||
marks = {r["path"] for r in erows if eng.is_watermark(r["path"], r["bytes"])}
|
||||
extras = {r["path"] for r in erows
|
||||
if r["path"] not in marks
|
||||
and eng.is_extra(r["path"], r["title"], r["dur"], ROOT,
|
||||
matched=r["path"] in PLEX_T)}
|
||||
eps = collections.defaultdict(list)
|
||||
for r in erows:
|
||||
if r["path"] in marks | extras:
|
||||
continue
|
||||
ep = eng.ep_of(r["path"])
|
||||
if ep:
|
||||
eps[ep[0]].append(r) # keyed by the SERIES, never the episode title
|
||||
tv_keys = {k for k, v in eps.items() if len(v) >= 5}
|
||||
tv_set = {r["path"] for k, v in eps.items() if k in tv_keys for r in v}
|
||||
# A folder INSIDE a movie's folder is bonus territory even when its own name is
|
||||
# not bonus vocabulary: "Saboteur (1942)/Exrtas/Saboteur - A Closer Look" is a
|
||||
# misspelled Extras folder, so the engine's folder rule never fired and the
|
||||
# featurette became a movie folder of its own — whose words then out-scored the
|
||||
# real Saboteur and drew that box set's other extras into it. The rule needs no
|
||||
# metadata: the folder holding the longest film owns the shorter video filed
|
||||
# under it, provided that shorter file's own name speaks the film's name — a
|
||||
# whole other film filed there stays a film, exactly as the engine's folder rule
|
||||
# exempts it. A different year in the file's own name IS that other film
|
||||
# speaking, so it vetoes the rule.
|
||||
def longest_of(rows):
|
||||
"""The longest video in each folder: that folder's feature."""
|
||||
out = {}
|
||||
for r in rows:
|
||||
if r["dur"] > 0:
|
||||
d = os.path.dirname(r["path"])
|
||||
if d not in out or r["dur"] > out[d]["dur"]:
|
||||
out[d] = r
|
||||
return out
|
||||
|
||||
|
||||
def not_disc(r):
|
||||
"""A disc tree's innards belong to the tree, never to the folder's film."""
|
||||
return not any(c in DISC_DIRS for c in r["path"].split("/"))
|
||||
|
||||
|
||||
FEATURE_OF = longest_of([r for r in erows if r["path"] not in marks and not_disc(r)])
|
||||
|
||||
|
||||
def under_a_feature(path, dur):
|
||||
"""True when a folder above this file holds a longer film that this file's
|
||||
own name names."""
|
||||
d = os.path.dirname(os.path.dirname(path))
|
||||
while len(d) > len(ROOT):
|
||||
f = FEATURE_OF.get(d)
|
||||
if f:
|
||||
if f["dur"] < dur:
|
||||
return False
|
||||
theirs = (words_of(os.path.basename(f["path"]))
|
||||
| words_of(os.path.basename(d)))
|
||||
if not words_of(os.path.basename(path)) & theirs:
|
||||
return False
|
||||
y1 = re.search(r"(?:18|19|20)\d\d", os.path.basename(path))
|
||||
y2 = re.search(r"(?:18|19|20)\d\d", os.path.basename(f["path"]))
|
||||
return not (y1 and y2 and y1.group() != y2.group())
|
||||
d = os.path.dirname(d)
|
||||
return False
|
||||
|
||||
|
||||
extras |= {r["path"] for r in erows
|
||||
if r["dur"] > 0 and r["path"] not in marks | extras | tv_set
|
||||
and not_disc(r) and under_a_feature(r["path"], r["dur"])}
|
||||
film_rows = [r for r in erows if r["path"] not in marks | extras | tv_set]
|
||||
|
||||
buckets = collections.defaultdict(list)
|
||||
for r in film_rows:
|
||||
buckets[eng.key_of(r["title"])].append(r)
|
||||
|
||||
|
||||
def res_label(r):
|
||||
m = RES_RE.search(r["res"] or "")
|
||||
if m:
|
||||
n, kind = m.group(1).lower(), m.group(2).lower()
|
||||
return "2160p" if n in ("2160", "4k") else f"{n}{kind}"
|
||||
try:
|
||||
w = int((r["res"] or "0x0").split("x")[0])
|
||||
except (ValueError, IndexError):
|
||||
w = 0
|
||||
return "2160p" if w >= 3000 else ("1080p" if w >= 1400 else "SD")
|
||||
|
||||
|
||||
def version_label(r):
|
||||
bits = [res_label(r)]
|
||||
vc = (r["vcodec"] or "").lower()
|
||||
if vc in ("av1", "libaom", "libsvtav1"):
|
||||
bits.append("AV1")
|
||||
elif vc in ("vc1", "wmv3"):
|
||||
bits.append("VC1")
|
||||
if r["bytes"] > 40 * 2**30:
|
||||
bits.append("REMUX")
|
||||
if "imax" in r["path"].lower():
|
||||
bits.append("IMAX")
|
||||
return sanitize(" ".join(bits))
|
||||
|
||||
|
||||
def group_name(items):
|
||||
titles = collections.Counter(r["title"] for r in items)
|
||||
ascii_t = [t for t in titles if t.isascii()]
|
||||
title = (collections.Counter(ascii_t) if ascii_t else titles).most_common(1)[0][0]
|
||||
years = [r["year"] for r in items if r["year"] != "?"] or \
|
||||
[r["soft"] for r in items if r["soft"]]
|
||||
year = collections.Counter(years).most_common(1)[0][0] if years else None
|
||||
return sanitize(f"{title} ({year})" if year else title)
|
||||
|
||||
|
||||
groups = []
|
||||
for key, items in buckets.items():
|
||||
for c in eng.dur_clusters(items):
|
||||
g = {"rows": c, "base": group_name(c)}
|
||||
known = [PLEX_T[r["path"]] for r in c if r["path"] in PLEX_T]
|
||||
if known:
|
||||
# one film, one name: a Plex match for any copy names every copy
|
||||
t, yr = collections.Counter(known).most_common(1)[0][0]
|
||||
g["plex"] = t
|
||||
g["base"] = sanitize(f"{t} ({yr})" if ok_year(yr) else t)
|
||||
groups.append(g)
|
||||
clash = {b for b, n in collections.Counter(g["base"] for g in groups).items() if n > 1}
|
||||
for g in groups:
|
||||
if g["base"] in clash:
|
||||
# two genuinely different films share title+year: separate folders, and
|
||||
# say so in the name, because Jellyfin can only match one item per folder
|
||||
g["base"] = sanitize(g["base"] + " - " + version_label(g["rows"][0]))
|
||||
g["flag"] = "same title and year as another film"
|
||||
|
||||
moves, mkdirs, problems, readmes = [], set(), [], []
|
||||
# --------------------------------------------------------------- unnamed groups
|
||||
# A movie folder has to READ as a title. The engine's runtime veto says any file
|
||||
# over 45 minutes that is not named like an extra is a film whatever it is called
|
||||
# — true for Jellyfin, but a 118-minute documentary named "Extra 1" then produced
|
||||
# a movie folder named "1", and folders named "1", "2", "3" are Jellyfin items
|
||||
# with no identity whose numeric keys went on to capture every subtitle named
|
||||
# "3_Eng.srt" in the library. A year proves a dated feature ("300 (2006)" is a
|
||||
# real film), so the gate is: a year, or a word that is not release noise. With
|
||||
# neither, the rows are bonus material until a human names them.
|
||||
def named(g):
|
||||
if re.search(r"\(\d{4}\)$", g["base"]):
|
||||
return True
|
||||
return any(w.isalpha() and len(w) >= 2 for w in eng.name_words(g["base"]))
|
||||
|
||||
|
||||
unnamed = [g for g in groups if not named(g)]
|
||||
groups = [g for g in groups if named(g)]
|
||||
for g in unnamed:
|
||||
for r in g["rows"]:
|
||||
extras.add(r["path"])
|
||||
problems.append(
|
||||
f"UNNAMED film {rel(r['path'])} parses to {g['base']!r} with no year: "
|
||||
f"bonus pile, not a folder — give the file a real name and re-plan")
|
||||
|
||||
|
||||
group_of = {}
|
||||
for g in groups:
|
||||
g["dir"] = f"{ROOT}/{g['base']}"
|
||||
for r in g["rows"]:
|
||||
group_of[r["path"]] = g
|
||||
dr = disc_root(r["path"])
|
||||
if dr:
|
||||
group_of[dr] = g
|
||||
by_path = {r["path"]: r for r in erows}
|
||||
|
||||
# -------------------------------------------------------------- bookkeeping
|
||||
claimed = {}
|
||||
existing = set(rows_map := {r["path"] for r in rows})
|
||||
|
||||
|
||||
at_target, booked_src = set(), set()
|
||||
|
||||
|
||||
def book(src, dst, kind):
|
||||
if src == dst:
|
||||
at_target.add(src)
|
||||
booked_src.add(src)
|
||||
return
|
||||
if os.environ.get("JF_TRACE") and (src in booked_src or dst.lower() in claimed):
|
||||
why = "rebook" if src in booked_src else "collision"
|
||||
problems.append(f"TRACE {why} kind={kind} src={rel(src)} dst={rel(dst)}")
|
||||
if src in booked_src: # one source may only ever move once
|
||||
problems.append(f"RE-BOOK {rel(src)} -> {rel(dst)} (already planned)")
|
||||
return
|
||||
if dst.lower() in claimed:
|
||||
problems.append(f"COLLISION {rel(dst)}\n from {rel(src)}\n and {rel(claimed[dst.lower()])}")
|
||||
return
|
||||
claimed[dst.lower()] = src
|
||||
booked_src.add(src)
|
||||
comp = dst.split("/")
|
||||
for i in range(1, len(comp)):
|
||||
mkdirs.add("/".join(comp[:i]))
|
||||
moves.append((src, dst, kind))
|
||||
|
||||
|
||||
def uniq_dir(d, stem, ext, hint=""):
|
||||
"""A destination that no other source already took."""
|
||||
cand = f"{d}/{stem}{ext}"
|
||||
if cand.lower() not in claimed:
|
||||
return cand
|
||||
n = 2
|
||||
while True:
|
||||
cand = f"{d}/{stem} {n}{ext}"
|
||||
if cand.lower() not in claimed:
|
||||
return cand
|
||||
n += 1
|
||||
|
||||
|
||||
def media_dir(p):
|
||||
dr = disc_root(p)
|
||||
return os.path.dirname(dr) if dr else os.path.dirname(p)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ films
|
||||
# film / disc moves: the largest copy carries the folder's own name, the rest
|
||||
# become Jellyfin versions; a disc tree moves as a whole into the movie folder
|
||||
|
||||
|
||||
def vext(p):
|
||||
return ext_by.get(p) or os.path.splitext(p)[1].lower()
|
||||
disc_children = collections.defaultdict(list)
|
||||
for q in rows:
|
||||
dr = disc_root(q["path"])
|
||||
if dr:
|
||||
disc_children[dr].append(q["path"])
|
||||
for g in groups:
|
||||
primary_taken = False
|
||||
disc_kinds = set()
|
||||
for r in sorted(g["rows"], key=lambda r: -r["bytes"]):
|
||||
dr = disc_root(r["path"])
|
||||
if dr:
|
||||
inner = os.path.basename(dr).upper()
|
||||
if inner in disc_kinds:
|
||||
problems.append(
|
||||
f"DISC-VERSION {g['base']}: extra disc tree {rel(dr)} cannot "
|
||||
f"share the folder — discs have no multiple versions")
|
||||
continue
|
||||
disc_kinds.add(inner)
|
||||
for src in disc_children[dr]:
|
||||
tail = os.path.relpath(src, dr)
|
||||
fold = disc_root(src, fold=False)
|
||||
if fold != dr and os.path.dirname(src) != dr:
|
||||
tail = os.path.basename(dr) + "/" + tail
|
||||
book(src, f"{g['dir']}/{tail}", "disc-file")
|
||||
continue
|
||||
label = "" if not primary_taken else f" - {version_label(r)}"
|
||||
dst = f"{g['dir']}/{g['base']}{label}{vext(r['path'])}"
|
||||
n = 2
|
||||
while dst.lower() in claimed:
|
||||
dst = f"{g['dir']}/{g['base']}{label} {n}{vext(r['path'])}"
|
||||
n += 1
|
||||
r["dst"] = dst
|
||||
primary_taken = True
|
||||
book(r["path"], dst, "film")
|
||||
g["primary"] = primary_taken
|
||||
|
||||
# ------------------------------------------------------------------ extras
|
||||
EXTRA_KIND = [("trailers", r"trailer|teaser|preview|promo|tv spot|advert"),
|
||||
("deleted scenes", r"deleted|alt scene|gag reel|blooper|outtake"),
|
||||
("interviews", r"interview|cast and crew|session piece|commentary"),
|
||||
("featurettes", r"featurette|behind the scenes|making of|documentary"
|
||||
r"|heritage|archival|video archive|music video"),
|
||||
("samples", r"sample"),
|
||||
("shorts", r"\bshort\b|short film"),
|
||||
("clips", r"\bclip\b|scene selection|montage|epilogue|isolated score"),
|
||||
("other", r"menu|logo|gallery|still|trivia|storyboard|screen test"
|
||||
r"|audition|test footage|watermark|workprint|poster"
|
||||
r"|picture in picture|singalong|karaoke|original score")]
|
||||
THEME_RE = re.compile(r"theme|opening song|ending song|soundtrack|score"
|
||||
r"|\bost\b|music|song", re.I)
|
||||
BACKDROP_RE = re.compile(r"backdrop|intro", re.I)
|
||||
|
||||
|
||||
def extras_kind(path):
|
||||
low = path.lower()
|
||||
if THEME_RE.search(low):
|
||||
return "theme-music"
|
||||
if BACKDROP_RE.search(low):
|
||||
return "backdrops"
|
||||
for name, rx in EXTRA_KIND:
|
||||
if re.search(rx, low):
|
||||
return name
|
||||
return "extras"
|
||||
|
||||
|
||||
def attachable_title(path):
|
||||
t, _y = eng.title_of(path)
|
||||
return eng.key_of(t) if t and not eng.is_watermark(path, size_by.get(path, 0)) else None
|
||||
|
||||
|
||||
for g in groups:
|
||||
g["tops"] = {parts_of(r["path"])[0] for r in g["rows"]}
|
||||
top_groups = collections.defaultdict(list)
|
||||
for g in groups:
|
||||
for t in g["tops"]:
|
||||
top_groups[t].append(g)
|
||||
group_by_key = collections.defaultdict(list)
|
||||
for g in groups:
|
||||
for r in g["rows"]:
|
||||
group_by_key[eng.key_of(r["title"])].append(g)
|
||||
group_by_dir = {}
|
||||
for g in groups:
|
||||
for r in g["rows"]:
|
||||
group_by_dir.setdefault(media_dir(r["path"]), g)
|
||||
dr = disc_root(r["path"])
|
||||
if dr:
|
||||
group_by_dir.setdefault(os.path.dirname(dr), g)
|
||||
|
||||
|
||||
def group_words(g):
|
||||
"""The words that identify a film group: its titles and the names of the
|
||||
folders its files live in."""
|
||||
if "words" not in g:
|
||||
w = set()
|
||||
if g.get("plex"):
|
||||
w |= words_of(g["plex"])
|
||||
for r in g["rows"]:
|
||||
w |= words_of(r["title"])
|
||||
w |= words_of(os.path.basename(media_dir(r["path"])))
|
||||
g["words"] = w
|
||||
return g["words"]
|
||||
|
||||
|
||||
def score_words(text, groups):
|
||||
"""Ranks film groups by how many of their words a path carries."""
|
||||
words = words_of(text)
|
||||
out = [(len(words & group_words(g)), g) for g in groups]
|
||||
out.sort(key=lambda x: -x[0])
|
||||
return out
|
||||
|
||||
|
||||
def group_for_orphan(path):
|
||||
"""Which movie folder an extra/sidecar belongs to. A collection folder holding
|
||||
several films must be resolved by WORDS, never by position: taking the first
|
||||
film of the folder attached every loose subtitle in a box set to whichever
|
||||
movie happened to sort first."""
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
key = attachable_title(path)
|
||||
if key and group_by_key.get(key):
|
||||
cands = group_by_key[key]
|
||||
top = parts_of(path)[0] if len(parts_of(path)) > 1 else ""
|
||||
same_top = [g for g in cands if any(parts_of(r["path"])[0] == top
|
||||
for r in g["rows"] if len(parts_of(r["path"])) > 1)]
|
||||
pool = same_top or cands
|
||||
return pool[0] if len(pool) == 1 else score_words(stem, pool)[0][1]
|
||||
gtop = parts_of(path)[0] if len(parts_of(path)) > 1 else ""
|
||||
pool = top_groups.get(gtop, [])
|
||||
if len(pool) > 1: # a loose collection folder (box set, bonus
|
||||
n, g = score_words(stem + " " + os.path.dirname(path), pool)[0]
|
||||
return g if n >= 2 else None # album) is resolved by words, not position
|
||||
if len(pool) == 1:
|
||||
return pool[0]
|
||||
d = os.path.dirname(path)
|
||||
while d and d != ROOT:
|
||||
if d in group_by_dir:
|
||||
return group_by_dir[d]
|
||||
d = os.path.dirname(d)
|
||||
return None
|
||||
|
||||
|
||||
tv_tops = {parts_of(p)[0] for p in tv_set}
|
||||
def film_folder_for(path):
|
||||
"""A movie folder for a lone feature that has no group: derived from its own
|
||||
title when a year proves it, else None (goes to the review pile)."""
|
||||
t, y = eng.title_of(path)
|
||||
if not t or y == "?":
|
||||
return None
|
||||
d = uniq_dir(ROOT, sanitize(f"{t} ({y})"), "", "")
|
||||
mkdirs.add(d)
|
||||
return d
|
||||
|
||||
|
||||
for p in sorted(extras):
|
||||
if disc_root(p):
|
||||
continue
|
||||
if len(parts_of(p)) > 1 and parts_of(p)[0] in tv_tops:
|
||||
continue # a special inside a TV set: staged with it
|
||||
g = group_for_orphan(p)
|
||||
ext = ext_by.get(p, os.path.splitext(p)[1].lower())
|
||||
stem = os.path.splitext(os.path.basename(p))[0]
|
||||
if g is None:
|
||||
solo = film_folder_for(p)
|
||||
if solo:
|
||||
dst = f"{solo}/{sanitize(stem)}{ext}"
|
||||
problems.append(f"REVIEW lone feature {rel(p)}: gets its own folder "
|
||||
f"{os.path.basename(solo)}")
|
||||
else:
|
||||
dst = f"{ROOT}/_unsorted bonus/{extras_kind(p)}/{sanitize(stem)}{ext}"
|
||||
problems.append(f"NO-FILM extra {rel(p)}")
|
||||
else:
|
||||
dst = f"{g['dir']}/{extras_kind(p)}/{sanitize(stem)}{ext}"
|
||||
book(p, uniq_dir(os.path.dirname(dst), os.path.basename(dst)[:-(len(ext) or 1)],
|
||||
ext, os.path.dirname(p)), "extra")
|
||||
|
||||
# Disc trees the engine never saw (a MakeMKV scratch folder, an AACS remnant):
|
||||
# they belong to the film of the folder they sit in, kept as a named subtree.
|
||||
for q in rows:
|
||||
pth = q["path"]
|
||||
dr = disc_root(pth)
|
||||
if not dr or pth in by_path or pth in booked_src:
|
||||
continue
|
||||
g = group_by_dir.get(dr) or (top_groups[parts_of(pth)[0]][0]
|
||||
if len(top_groups.get(parts_of(pth)[0], [])) == 1 else None)
|
||||
if g is None:
|
||||
continue
|
||||
inner = os.path.basename(dr)
|
||||
problems.append(f"REVIEW disc remnant {rel(dr)} moves inside {g['base']} "
|
||||
f"(a second master: compare before keeping both)")
|
||||
for src in disc_children[dr]:
|
||||
if src not in booked_src:
|
||||
book(src, f"{g['dir']}/{inner}/{os.path.relpath(src, dr)}", "disc-file")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- sidecars
|
||||
media_by_dir = collections.defaultdict(list)
|
||||
for r in erows:
|
||||
if r["path"] in marks:
|
||||
continue
|
||||
dst = r.get("dst")
|
||||
if dst:
|
||||
media_by_dir[media_dir(r["path"])].append(r)
|
||||
|
||||
|
||||
|
||||
def sidecar_target(path, e):
|
||||
d = os.path.dirname(path)
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
if d == ROOT:
|
||||
# A file loose in the library ROOT names no film. media_by_dir[ROOT] is the
|
||||
# set of root-level MOVIE files, and scoring a stray name against it
|
||||
# attached every root-level leftover to whichever root-level film was
|
||||
# largest — the plan sent ".DS_Store" into "Titanic (1997)/other/" that
|
||||
# way. A root-level stray is nobody's sidecar: it goes to the review pile.
|
||||
return None, None
|
||||
cands = media_by_dir.get(d, [])
|
||||
if not cands:
|
||||
nd = os.path.dirname(d) # a subs/ or Extras/ folder beside the media
|
||||
while nd and nd != ROOT and not media_by_dir.get(nd):
|
||||
nd = os.path.dirname(nd)
|
||||
if media_by_dir.get(nd):
|
||||
if nd != d: # a NAMED subfolder (Subs/, Extras/, the
|
||||
g = group_for_orphan(path) # box set's own root) carries evidence
|
||||
dirs = {os.path.dirname(r.get("dst", "")) for r in media_by_dir[nd]}
|
||||
if g is None or g["dir"] not in dirs:
|
||||
# ...but only a STRONG word match outranks the folder the file
|
||||
# sits in. A weak one is an accident: "3_Eng.srt" scored 1
|
||||
# against a junk group named "3" and escaped its own movie's
|
||||
# Subs/ folder, while "03 xXx 3 Return Of Xander Cage" scores 4
|
||||
# and genuinely belongs to the other film of the set.
|
||||
n = score_words(stem, [g])[0][0] if g else 0
|
||||
if n >= 2:
|
||||
return (g, None)
|
||||
cands = media_by_dir[nd]
|
||||
if not cands:
|
||||
g = group_for_orphan(path)
|
||||
return (g, None) if g else (None, None)
|
||||
if len(cands) > 1:
|
||||
w = [t for t in eng.norm(stem).split() if t not in NOISE]
|
||||
cands = sorted(cands, key=lambda r: (
|
||||
-len([t for t in eng.norm(os.path.basename(r["path"])).split() if t in w]),
|
||||
-r["bytes"]))
|
||||
return (None, cands[0])
|
||||
|
||||
|
||||
for q in rows:
|
||||
p, e = q["path"], q["ext"]
|
||||
if p in by_path or p in extras or p in marks or is_qb(p) or disc_root(p):
|
||||
continue
|
||||
if os.path.basename(p).startswith("._"):
|
||||
# macOS AppleDouble resource forks: a few hundred bytes carrying a name
|
||||
# COPIED FROM ANOTHER FILM. Word-matching them to "their" film scattered
|
||||
# dead files through four movie folders (and they are video extensions in
|
||||
# the AppleDouble case, so they also skipped every other filter). They
|
||||
# claim nothing: one pile, and they stay in it — nothing is deleted here.
|
||||
book(p, uniq_dir(f"{ROOT}/_macos sidecars", sanitize(os.path.basename(p))[:120],
|
||||
"", os.path.dirname(p)), "junk")
|
||||
problems.append(f"JUNK AppleDouble sidecar {rel(p)} (macOS resource fork)")
|
||||
continue
|
||||
if e == ".iso": # a DVD image: Jellyfin plays it as a movie file
|
||||
g = group_for_orphan(p)
|
||||
stem = os.path.splitext(os.path.basename(p))[0]
|
||||
if g is not None:
|
||||
book(p, uniq_dir(g["dir"], g["base"], e, stem) if g.get("primary") is not None
|
||||
else f"{g['dir']}/{g['base']}{e}", "iso")
|
||||
else:
|
||||
solo = film_folder_for(p)
|
||||
if solo:
|
||||
book(p, f"{solo}/{sanitize(stem)}.iso", "iso")
|
||||
problems.append(f"REVIEW disc image {rel(p)}: DVD rip, alone in its "
|
||||
f"folder -> {os.path.basename(solo)}")
|
||||
else:
|
||||
book(p, f"{ROOT}/_unsorted files/{sanitize(os.path.basename(p))}", "orphan")
|
||||
problems.append(f"NO-FILM disc image {rel(p)}")
|
||||
continue
|
||||
if e in VIDEO_EXT:
|
||||
# A video the engine never probed (unreadable header) and not an AppleDouble
|
||||
# remnant: the plan does not guess at it, so it stays where it is.
|
||||
continue
|
||||
stem = os.path.splitext(os.path.basename(p))[0]
|
||||
g, media = sidecar_target(p, e)
|
||||
if media is not None:
|
||||
base = os.path.splitext(os.path.basename(media["dst"]))[0]
|
||||
d = os.path.dirname(media["dst"])
|
||||
elif g is not None:
|
||||
base, d = g["base"], g["dir"]
|
||||
else:
|
||||
# Nothing names this file's film. NEVER scatter it: an archive set or an
|
||||
# album tree must move as the unit it is, so the whole loose folder goes
|
||||
# to the review pile and a film folder's own leftovers stay with it.
|
||||
ps = parts_of(p)
|
||||
if len(ps) < 2:
|
||||
dst = f"{ROOT}/_unsorted files/{sanitize(stem)}{e}"
|
||||
elif len(top_groups.get(ps[0], [])):
|
||||
dst = f"{top_groups[ps[0]][0]['dir']}/other/{'/'.join(sanitize(c) for c in ps[1:])}"
|
||||
else:
|
||||
dst = f"{ROOT}/_unsorted files/{'/'.join(sanitize(c) for c in ps)}"
|
||||
book(dst=dst, src=p, kind="orphan")
|
||||
problems.append(f"NO-OWNER sidecar {rel(p)}")
|
||||
continue
|
||||
if e in IMG_EXT:
|
||||
low = eng.norm(stem)
|
||||
word = next((w for w in IMG_PRIMARY if re.search(rf"\b{w}\b", low)), None)
|
||||
kind = "poster" if word else next(
|
||||
(IMG_OTHER[w] for w in IMG_OTHER if re.search(rf"\b{w}\b", low)), None)
|
||||
if re.search(r"\btheme\b", low):
|
||||
dst = f"{d}/theme{e}"
|
||||
else:
|
||||
dst = f"{d}/{base}-{kind or 'art'}{e}" if kind else f"{d}/{sanitize(stem)}{e}"
|
||||
book(p, uniq_dir(os.path.dirname(dst), os.path.basename(dst)[:-(len(e) or 1)],
|
||||
e, os.path.dirname(p)), "image")
|
||||
elif e in NFO_EXT:
|
||||
book(p, uniq_dir(d, base, ".nfo", os.path.dirname(p)), "nfo")
|
||||
elif e in SUB_EXT | AUDIO_EXT:
|
||||
toks = [t for t in re.split(r"[._\-\s]+", stem) if t]
|
||||
langs = [t.lower() for t in toks if t.lower() in LANGS]
|
||||
flags = [t.lower() for t in toks if t.lower() in FLAGS]
|
||||
keep = [sanitize(t) for t in toks
|
||||
if t.lower() not in langs + flags + list(NOISE)
|
||||
and not re.fullmatch(r"\d{3,4}p?|cd\d+|\d{4}|\d+ch|\d+.\d", t.lower())]
|
||||
bits = [base] + ([sanitize(" ".join(keep[:4]))] if keep else []) + langs + flags
|
||||
if e in SUB_EXT:
|
||||
# subtitles: <movie>/srt[/language]
|
||||
d = f"{d}/srt"
|
||||
pd = os.path.basename(os.path.dirname(p))
|
||||
if SUB_KEEP_RE.match(pd) and not SUB_DIR_OK.match(pd):
|
||||
d = f"{d}/{sanitize(pd)}" # a real language folder survives
|
||||
elif e in AUDIO_EXT and AUDIO_DIR_RE.search(os.path.dirname(p)):
|
||||
d = f"{d}/theme-music" # bonus audio: one canonical folder
|
||||
pd = os.path.basename(os.path.dirname(p))
|
||||
if not MUSIC_TOP_RE.search(pd): # an album folder keeps its name
|
||||
d = f"{d}/{sanitize(pd)}"
|
||||
else:
|
||||
pd = os.path.basename(os.path.dirname(p))
|
||||
if not AUDIO_DIR_OK.match(pd):
|
||||
d = f"{d}/{sanitize(pd)}"
|
||||
stem2 = ".".join(bits) or sanitize(os.path.basename(p))
|
||||
book(p, uniq_dir(d, stem2, e, os.path.basename(p)), "track")
|
||||
else:
|
||||
book(p, uniq_dir(f"{d}/other", sanitize(os.path.basename(p))[:120], e,
|
||||
os.path.dirname(p)), "misc")
|
||||
|
||||
readmes.append(f"README in {ROOT}: this library was relaid out into Jellyfin's "
|
||||
"one-folder-per-movie layout; `_release-group watermarks/`, "
|
||||
"`_unsorted files/` and `_unsorted bonus/` hold files no film claims.")
|
||||
for key in tv_keys:
|
||||
readmes.append(f"README in {ROOT}/{sanitize('_TV needs a TV library - ' + key)}: "
|
||||
"these are TV episodes found in the MOVIES library; move the folder "
|
||||
"to a TV library source, else Jellyfin groups them wrongly as one "
|
||||
"movie.")
|
||||
|
||||
# --------------------------------------------------------------------- TV
|
||||
for key in tv_keys:
|
||||
items = eps[key]
|
||||
year = collections.Counter(r["year"] for r in items if r["year"] != "?")
|
||||
yr = year.most_common(1)[0][0] if year else None
|
||||
base = f"_TV needs a TV library - {sanitize(key)}" + (f" ({yr})" if yr else "")
|
||||
tops = {parts_of(r["path"])[0] for r in items}
|
||||
members = sorted([r["path"] for r in items]
|
||||
+ [q for q in extras | marks if len(parts_of(q)) > 1
|
||||
and parts_of(q)[0] in tops],
|
||||
key=lambda q: q)
|
||||
for src in members:
|
||||
rel_parts = rel(src).split("/")
|
||||
tail = "/".join(sanitize(c) for c in rel_parts[:-1] + [rel_parts[-1]])
|
||||
book(src, f"{ROOT}/{base}/{tail}", "tv")
|
||||
for p in sorted(marks):
|
||||
stem, ext = os.path.splitext(os.path.basename(p))
|
||||
book(p, uniq_dir(f"{ROOT}/_release-group watermarks", sanitize(stem)[:120], ext,
|
||||
os.path.dirname(p)), "watermark")
|
||||
for q in rows:
|
||||
if is_qb(q["path"]):
|
||||
problems.append(f"SKIPPED qBittorrent partial {rel(q['path'])}")
|
||||
book(q["path"], q["path"], "qb-partial") # stays: resume data
|
||||
|
||||
# --------------------------------------------------------------- debug hook
|
||||
if os.environ.get("JF_ISO"):
|
||||
x = ROOT + "/High Anxiety (Mel Brooks) [1977] nordic subs DVDrip/High Anxiety [1977].iso"
|
||||
print("attachable", attachable_title(x), "| dir", os.path.dirname(x) in group_by_dir,
|
||||
"| g", group_for_orphan(x) and group_for_orphan(x)["base"])
|
||||
raise SystemExit
|
||||
if os.environ.get("JF_DEBUG"):
|
||||
import json
|
||||
unp = sorted({r["path"] for r in rows} - {m[0] for m in moves} - at_target)
|
||||
print("at_target", len(at_target))
|
||||
print("kinds-unplanned", collections.Counter(os.path.splitext(u)[1] for u in unp).most_common(8))
|
||||
for up in unp[:20]:
|
||||
d = os.path.dirname(up)
|
||||
print("UNP", up[:90], "| mediadir?", d in media_by_dir, "| by_path", up in by_path,
|
||||
"| extras", up in extras, "| discroot", disc_root(up), "| ext", os.path.splitext(up)[1])
|
||||
|
||||
# -------------------------------------------------------------- validation
|
||||
srcs = [m[0] for m in moves]
|
||||
dup_src = {s for s, n in collections.Counter(srcs).items() if n > 1}
|
||||
for s in dup_src:
|
||||
problems.append(f"DOUBLE-CLAIM source {rel(s)}")
|
||||
all_src = set(srcs)
|
||||
untouched = {r["path"] for r in rows} - all_src
|
||||
bad_names, into_alive, lost = [], [], []
|
||||
for src, dst, kind in moves:
|
||||
for comp in dst[len(ROOT) + 1:].split("/"):
|
||||
if (BAD_CHARS.search(comp) or comp != comp.strip(". ") or not comp
|
||||
or len(comp) > 200):
|
||||
bad_names.append(f"{kind}: {comp!r} in {rel(dst)}")
|
||||
if dst in untouched: # something staying put is there
|
||||
into_alive.append(f"{rel(src)} -> {rel(dst)} (occupied by a file that stays)")
|
||||
if dst.lower() in {s.lower() for s in untouched}:
|
||||
into_alive.append(f"{rel(src)} -> {rel(dst)} (case-collides with a survivor)")
|
||||
if dst.startswith(src + "/") and src in all_src:
|
||||
lost.append(f"{rel(src)} moves into its own subtree")
|
||||
# every accounted-for source must exist exactly once in the plan
|
||||
covered = {r["path"] for r in rows}
|
||||
not_planned = sorted(covered - all_src - at_target)
|
||||
|
||||
with open(PLAN, "w") as out:
|
||||
out.write("action\tsrc\tdst\tkind\n")
|
||||
for d in sorted(mkdirs, key=lambda q: (q.count("/"), q)):
|
||||
if d.startswith(ROOT + "/") and d != ROOT:
|
||||
out.write(f"mkdir\t\t{d}\tdir\n")
|
||||
for p in sorted(booked_src - {m[0] for m in moves}):
|
||||
out.write(f"keep\t{p}\t{p}\n")
|
||||
for src, dst, kind in sorted(moves, key=lambda m: m[0]):
|
||||
out.write(f"move\t{src}\t{dst}\t{kind}\n")
|
||||
|
||||
kinds = collections.Counter(k for _s, _d, k in moves)
|
||||
films_n = sum(1 for _s, _d, k in moves if k == "film")
|
||||
folders = len({os.path.dirname(d) for _s, d, k in moves if k in ("film", "disc-file")})
|
||||
with open(EXCEPTS, "w") as out:
|
||||
out.write(f"groups={len(groups)} moves={len(moves)} folders={folders}\n")
|
||||
out.write(f"kinds={dict(kinds)}\n")
|
||||
out.write(f"at-target-already={len(at_target)} unplanned={len(not_planned)}\n")
|
||||
out.write("\n=== WHAT THE EXECUTOR MUST ALSO WRITE ===\n")
|
||||
out.write("\n".join(sorted(set(readmes))) + "\n")
|
||||
out.write("\n=== PROBLEMS ===\n")
|
||||
out.write("\n".join(sorted(set(problems))) + "\n")
|
||||
out.write("\n=== BAD NAMES ===\n" + "\n".join(sorted(set(bad_names))) + "\n")
|
||||
out.write("\n=== TARGETS OCCUPIED BY SURVIVORS ===\n"
|
||||
+ "\n".join(sorted(set(into_alive))) + "\n")
|
||||
out.write("\n=== LEFTOVER SOURCES (first 60) ===\n"
|
||||
+ "\n".join(rel(p) for p in not_planned[:60]) + "\n")
|
||||
print(f"groups={len(groups)} folders={folders} moves={len(moves)} "
|
||||
f"mkdirs={len(mkdirs)} film-files={films_n}")
|
||||
print(f"kinds={dict(kinds)}")
|
||||
print(f"problems={len(set(problems))} bad-names={len(set(bad_names))} "
|
||||
f"occupied={len(set(into_alive))} double-claim={len(dup_src)}")
|
||||
print(f"left-unmoved={len(not_planned)}")
|
||||
print(f"tv-keys={len(tv_keys)} extras={len(extras)} marks={len(marks)} "
|
||||
f"disc-trees={len(disc_children)}")
|
||||
@@ -0,0 +1,35 @@
|
||||
# The operator's tvdb id map: `folder <TAB> tvdb id <TAB> note`.
|
||||
#
|
||||
# These are the pack folders whose show tvdb's NAME SEARCH cannot reach: the
|
||||
# record exists, but tvdb titles it in the show's own language and the English
|
||||
# name rides in aliases the search never reads. Without a row here the folder
|
||||
# keeps the release folder's words and no episode title; with one, both matchers
|
||||
# read `[tvdbid-NNNN]` and the files rename to tvdb's own name and titles.
|
||||
#
|
||||
# Every id was read from tvdb's own record (`/series/<id>/extended`) on
|
||||
# 2026-10-31, and the note states what that record says plus the neighbouring
|
||||
# record a name search would have picked instead — the row is checkable without
|
||||
# re-searching, because one wrong id renames a whole pack to the wrong show. The
|
||||
# plan refuses a line whose id is not a number and lists every folder the map
|
||||
# touched in the exceptions file.
|
||||
#
|
||||
# Folders the name search DOES reach (the EXACT and PUNCT classes of the proposal
|
||||
# table) stay off this file on purpose: a map row would hide a live lookup behind
|
||||
# a number nobody re-checks.
|
||||
[Anime Time] Pokemon Complete Series (Season 01-25+Movies+Specials+Shorts+OST) [DVD+BD][1080p][HEVC 10bit x265][AAC] 76703 tvdb: ポケットモンスター (1997), the umbrella record for every era this pack spans; 1268 files
|
||||
[Anime Time] One Piece (0001-1071+Movies+Specials) [BD+CR] [Dual Audio] [1080p][HEVC 10bit x265][AAC][Multi Sub] 81797 tvdb: ワンピース (1999), alias ONE PIECE; the bare alias "One Piece" belongs to 392276, the 2023 live action; 1072 files
|
||||
[Judas] Naruto (Complete Series) [BD 1080p][HEVC x265 10bit][Multi-Audio][Multi-Subs] 78857 tvdb: NARUTO-ナルト- (2002), alias "Naruto" carries the full-width dashes; 220 files
|
||||
Star Trek Deep Space 9 Deluxe DVD Boxset + Extras DVDRip TSV 72073 tvdb: Star Trek: Deep Space Nine (1993), alias "Star Trek Deep Space 9"; the box set's own words match nothing; 279 files
|
||||
Top Gear UK 1-17 74608 tvdb: Top Gear (2002), no "UK" alias — the US version is 199051; 131 files
|
||||
Black Clover 001 331753 tvdb: ブラッククローバー (2017), which holds NO alias at all; 378762 is Mugyutto! Black Clover (2019), a different show; 108 files
|
||||
Shin Chan - Complete Eng Dub Series 2160p Ai Upscaled -Mesc 437356 tvdb: Shin Chan (2006) — the English dub record this pack is; 79654 is the 1992 Japanese original; 78 files
|
||||
Fairy Tail (2009-2014) (Seasons 1-8 + OVAs) (BD 1080p)(HEVC x265 10bit)(Dual-Audio)(Eng-Subs)-Judas[TGx] 114801 tvdb: フェアリーテイル (2009), alias FAIRY TAIL; 410031 is FAIRY TAIL 100 NEN QUEST (2024), the sequel; 59 files
|
||||
(CBB) Tokyo Ghoul - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-10bit-x265) 281249 tvdb: 東京喰種トーキョーグール (2014) — :re and √A are seasons of THIS record, confirmed by search; 58 files
|
||||
[Anime Time] Demon Slayer - Kimetsu no Yaiba (S01+S02+S03+S04+Mugen Train Arc+Movie) [BD] [Dual Audio][1080p][HEVC 10bit x265][AAC][Eng Sub] 348545 tvdb: 鬼滅の刃 (2019), alias "Demon Slayer: Kimetsu no Yaiba" — the folder's hyphen splits it differently; 56 files
|
||||
(CBB) Tengen Toppa Gurren Lagann - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-x265-10bit) 80096 tvdb: 天元突破グレンラガン (2007), alias Tengen Toppa Gurren Lagann; 51 files
|
||||
Fullmetal Alchemist - Brotherhood 85249 tvdb: 鋼の錬金術師 FULLMETAL ALCHEMIST (2009), alias "Full Metal Alchemist Brotherhood" — spaced, unlike this folder; 64 files
|
||||
[NewbSubs] Attack on Titan~Shingeki no Kyojin Series (1080p Blu-ray x265 Dual Audio) 267440 tvdb: 進撃の巨人 (2013), aliases carry "Attack on Titan" and "Shingeki no Kyojin", joined here by a tilde; 27 files
|
||||
Kimetsu no Yaiba 348545 the same record as the Anime Time pack above: two release folders, ONE show; 12 files
|
||||
Rick and Morty The Anime 420279 tvdb: リック・アンド・モーティ:ジ・アニメ (2024), no English alias on the record; 10 files
|
||||
[Anime Time] Dorohedoro (Season 1+OVA+Extra) [Dual Audio][BD][1080p][HEVC 10bit x265][AAC][Eng Sub] 370761 tvdb: ドロヘドロ (2020), alias Dorohedoro; 9 files
|
||||
Ken Il Guerriero HOKUTO NO KEN 79156 tvdb: 北斗の拳 (1984), alias "Hokuto no Ken"; the Italian title matches nothing and 465216 is the 2026 remake; 5 files
|
||||
|
Can't render this file because it contains an unexpected character in line 20 and column 182.
|
Executable
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Ask tvdb what it calls the shows a plan renamed WITHOUT a tvdb id.
|
||||
|
||||
media-shows-tvdb.py [--plan TSV] [--root DIR] [--park DIR] [--map TSV]
|
||||
[--out TSV] [--credentials FILE]
|
||||
|
||||
`media-shows.py` renames a series to tvdb's own words only when an id backs the
|
||||
name; everything else keeps the metadata provider's words and no episode title,
|
||||
and the plan's mkdir rows are where those folders show up. This script turns that
|
||||
list into a decision sheet: one row per destination series folder that carries no
|
||||
`[tvdbid-NNNN]`, with what tvdb's search answers for it and a verdict that says
|
||||
HOW MUCH was guessed to get there.
|
||||
|
||||
It changes nothing. The sheet is for the operator; the file that changes
|
||||
behaviour is `scripts/media-shows-tvdb-ids.tsv`, whose rows he approves. That is
|
||||
the loop: plan -> this sheet -> approved map rows -> re-plan.
|
||||
|
||||
Verdicts, weakest last:
|
||||
|
||||
EXACT tvdb's own name equals the folder's words as they stand.
|
||||
PUNCT it equals them only after release junk is stripped (pack numbers,
|
||||
codecs, group names, `Complete Series`); the difference is spelling,
|
||||
not identity.
|
||||
NEAR tvdb answered something whose name is NOT the query. The best hit is
|
||||
printed so a human can judge it, and it is never applied by anything.
|
||||
NONE nothing left to ask with, or no answer at all.
|
||||
|
||||
Acceptance is deliberately unforgiving — a hit counts only when tvdb's name
|
||||
equals the query with every non-alphanumeric dropped from both sides, and the
|
||||
series' year still has to fall inside any year span the folder names. The
|
||||
cleaner is the only thing that got smarter over the passes this tool replaces;
|
||||
the acceptance test never moved, because a wrong id renames a whole pack to the
|
||||
wrong show. A query that needs the cleaner is reported as PUNCT for exactly that
|
||||
reason: the machine stripped words, so a human reads the row.
|
||||
|
||||
Read-only against tvdb. The credential file is the planner's own
|
||||
(`api_key=`, optional `pin=`), read here and never printed.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ARGS = argparse.ArgumentParser(description=__doc__.splitlines()[0],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ARGS.add_argument("--plan", default="/tmp/tv-plan.tsv",
|
||||
help="the plan media-shows.py just wrote (its mkdir rows are "
|
||||
"the folders this sheet asks about)")
|
||||
ARGS.add_argument("--root", default=os.environ.get("TV_ROOT", "/mnt/entropy/TVShows"),
|
||||
help="the TV library the plan renames")
|
||||
ARGS.add_argument("--park", default="",
|
||||
help="the review pile inside it (its folders are not shows)")
|
||||
ARGS.add_argument("--map",
|
||||
default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"media-shows-tvdb-ids.tsv"),
|
||||
help="the operator's approved map; its folders are reported as "
|
||||
"MAPPED and never asked again")
|
||||
ARGS.add_argument("--credentials",
|
||||
default=os.path.expanduser("~/.config/secrets/tvdb"),
|
||||
help="file with an api_key= line (and a pin= for a user-issued "
|
||||
"key); mode 600, and never printed")
|
||||
ARGS.add_argument("--out", default="/tmp/tvdb-proposal.tsv",
|
||||
help="the decision sheet")
|
||||
ARGS = ARGS.parse_args()
|
||||
|
||||
ROOT = os.path.abspath(ARGS.root)
|
||||
PARK = os.path.abspath(ARGS.park or os.path.join(ROOT, "_unsorted"))
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"eng", os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"media-titles.py"))
|
||||
eng = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(eng)
|
||||
|
||||
# Release metadata, not the show: everything a scene folder name says about the
|
||||
# ENCODING, the packaging or the languages, which tvdb has never heard of.
|
||||
JUNK = re.compile(
|
||||
r"complete|series|dual|multi|audio|sub|subs|multisub|dualaudio|"
|
||||
r"\d{3,4}p|1080p|720p|2160p|480p|4k|hd|hdtv|cam|hdcam|telesync|x264|x265|hevc|avc|"
|
||||
r"\d+bit|bit|bluray|blu-?ray|bdrip|web-?rip|web-?dl|webrip|hdtvrip|"
|
||||
r"dvdrip|dvdr|dvdscr|tsr|tsv|mkv|avi|mp4|aac|ac3|dts|eac3|flac|ddp?5[.1]1|"
|
||||
r"bd|dvd|rip|boxset|box|season|seasons|episodes?|specials?|shorts|ost|"
|
||||
r"ova|ovas|oad|repack|proper|internal|mi?xed|budgetbits|deluxe|remux|"
|
||||
r"remastered|uncut|collector|edition|extras?|volume|volumes|part|"
|
||||
r"disc|disk|cd|ntsc|pal|region|fixed|patched|proof|english|esp|"
|
||||
r"espagnol|french|vostfr|subtitul\w*|spanish|german|italian|ita|eng|fre|spa|lat|"
|
||||
r"upscaled?|ai|dd|movie|movies",
|
||||
re.I)
|
||||
GROUP = re.compile(r"KONTRAST|TGx|Judas|Silence|FreetheFilms|NTb|DEADPOOL|Xploit|"
|
||||
r"SM737|TSA|3OISL3R|PLAYWEB|EPiC|FLUX|HONEY|Zaxton|Cca3rW|"
|
||||
r"monikeres|frank|hdh|psi|rtfpemaker|cbm|web|asser|astro|"
|
||||
r"yfn|uindex|www|org|com|net|mesc|ataraxiaprime|seph\d*|"
|
||||
r"[a-z]+subs?|edge", re.I)
|
||||
BRACKET = re.compile(r"[\[({]([^\])}]*)[\])}]")
|
||||
NUM_RANGE = re.compile(r"(?<!\w)\d{1,4}\s*(?:-|–|to|through)\s*\d{1,4}(?!\w)", re.I)
|
||||
INSIDE = re.compile(r"all|complete|full|dual|multi|audio|sub|subs|ova|ovas|"
|
||||
r"movies?|episodes?|extras?|bonus|pack|remux|region\s*\d*|"
|
||||
r"bd|dvd|cd|ntsc|pal|hr-?dvd", re.I)
|
||||
|
||||
|
||||
def drop_bracket(m):
|
||||
"""A bracket says nothing when its words are junk; keep it when they aren't."""
|
||||
toks = [t for t in re.split(r"[\s_.,+~&]+", m.group(1)) if t]
|
||||
if not toks or all(INSIDE.fullmatch(t) or JUNK.fullmatch(t)
|
||||
or GROUP.fullmatch(t)
|
||||
or re.fullmatch(r"\d+[a-z]?", t, re.I)
|
||||
or re.fullmatch(r"(19|20)\d\d", t) for t in toks):
|
||||
return " "
|
||||
return " " + m.group(1) + " "
|
||||
|
||||
|
||||
def clean(name):
|
||||
"""The folder's words with everything about the RELEASE taken out, so what is
|
||||
left is what a person would type into tvdb. `Star Trek Deep Space 9 Deluxe
|
||||
DVD Boxset + Extras DVDRip TSV` becomes `Star Trek Deep Space 9`; `SNL 16-20`
|
||||
becomes `SNL`."""
|
||||
name = re.sub(r"^\s*www\b.*?\b(org|com|net)\b\s*[-–]\s*", " ", name, flags=re.I)
|
||||
name = re.sub(r"\bS\d{1,2}([ .Ee]\d+)?[-–Ee\d.]*", " ", name)
|
||||
name = re.sub(r"\b\d{1,2}[xX]\d{1,3}\b", " ", name)
|
||||
name = NUM_RANGE.sub(" ", name)
|
||||
name = re.sub(r"(19|20)\d\d(?:\s*[-/–]\s*(?:19|20)\d\d)?", " ", name)
|
||||
name = re.sub(r"\bby\s+[A-Za-z0-9_.-]+$", " ", name)
|
||||
name = BRACKET.sub(drop_bracket, name)
|
||||
name = re.sub(r"[\[()\]:,;!?*]", " ", name)
|
||||
toks = re.split(r"[\s_.\-+~&]+", name)
|
||||
out = []
|
||||
for i, t in enumerate(toks):
|
||||
if not t or JUNK.fullmatch(t) or GROUP.fullmatch(t):
|
||||
continue
|
||||
if re.fullmatch(r"\d+", t):
|
||||
after = sum(1 for x in toks[i + 1:] if x
|
||||
and not JUNK.fullmatch(x) and not GROUP.fullmatch(x)
|
||||
and not re.fullmatch(r"\d+", x))
|
||||
# A pack index (`001`) and a lead-in number over a real title
|
||||
# (`01 Charles Barkley`) are noise; `30 Rock`, `24`, `Deep Space 9`
|
||||
# are the title itself.
|
||||
if len(t) >= 3 or (not out and after >= 2):
|
||||
continue
|
||||
out.append(t)
|
||||
while out and out[-1].lower() in ("and", "with", "the", "a", "an", "to", "of"):
|
||||
out.pop()
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def years(name):
|
||||
return [int(y) for y in re.findall(r"\b(19\d\d|20\d\d)\b", name)]
|
||||
|
||||
|
||||
def bare(s):
|
||||
return re.sub(r"[^a-z0-9]", "", s.lower())
|
||||
|
||||
|
||||
CRED = {}
|
||||
try:
|
||||
for _line in open(ARGS.credentials):
|
||||
if "=" in _line:
|
||||
k, v = _line.strip().split("=", 1)
|
||||
CRED[k.strip()] = v.strip()
|
||||
except OSError:
|
||||
print(f"tvdb: no credentials at {ARGS.credentials} — the sheet would be all "
|
||||
"NEAR and NONE, which is not a decision sheet", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if not CRED.get("api_key"):
|
||||
print(f"tvdb: no api_key in {ARGS.credentials}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
TOKEN = {"jwt": ""}
|
||||
|
||||
|
||||
def call(path):
|
||||
"""One tvdb v4 GET, logging in again once when the JWT expires mid-run."""
|
||||
for attempt in (1, 2):
|
||||
if not TOKEN["jwt"]:
|
||||
body = {"apiKey": CRED["api_key"]}
|
||||
if CRED.get("pin"):
|
||||
body["token"] = CRED["pin"]
|
||||
req = urllib.request.Request("https://api4.thetvdb.com/v4/login",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={"Content-Type":
|
||||
"application/json"})
|
||||
TOKEN["jwt"] = json.load(
|
||||
urllib.request.urlopen(req, timeout=30))["data"]["token"]
|
||||
try:
|
||||
req = urllib.request.Request("https://api4.thetvdb.com/v4" + path,
|
||||
headers={"Authorization":
|
||||
"Bearer " + TOKEN["jwt"]})
|
||||
return json.load(urllib.request.urlopen(req, timeout=30))["data"]
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401 and attempt == 1:
|
||||
TOKEN["jwt"] = ""
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
MAPPED = {}
|
||||
if ARGS.map and os.path.exists(ARGS.map):
|
||||
for _line in open(ARGS.map):
|
||||
_line = _line.rstrip("\n")
|
||||
if not _line.strip() or _line.startswith("#"):
|
||||
continue
|
||||
cols = _line.split("\t")
|
||||
if len(cols) > 1 and cols[1].strip().isdigit():
|
||||
MAPPED[cols[0].strip()] = cols[1].strip()
|
||||
|
||||
# The folders the plan created inside the library, with the file count each one
|
||||
# will hold. The review pile is not a show: its files belong to no identity, and
|
||||
# naming the pile would put one junk series on the sheet for all of them.
|
||||
shows = collections.Counter()
|
||||
for line in open(ARGS.plan).read().splitlines()[1:]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 4 or parts[0] != "mkdir":
|
||||
continue
|
||||
dst = os.path.abspath(parts[2])
|
||||
if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep):
|
||||
continue
|
||||
top = os.path.relpath(dst, ROOT).split(os.sep)[0]
|
||||
if top.startswith((".", "_")):
|
||||
continue
|
||||
if "[tvdbid-" in top:
|
||||
continue # an id already backs it
|
||||
shows.setdefault(top, 0)
|
||||
for line in open(ARGS.plan).read().splitlines()[1:]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 4 or parts[0] != "move":
|
||||
continue
|
||||
dst = os.path.abspath(parts[2])
|
||||
if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep):
|
||||
continue
|
||||
top = os.path.relpath(dst, ROOT).split(os.sep)[0]
|
||||
if "[tvdbid-" in top:
|
||||
continue
|
||||
shows[top] += 1
|
||||
|
||||
tally = collections.Counter()
|
||||
rows = []
|
||||
for folder in sorted(shows, key=lambda f: (-shows[f], f)):
|
||||
files, tag, note = shows[folder], "NONE", ""
|
||||
if folder in MAPPED:
|
||||
rows.append([files, "MAPPED", folder, "", "", "", MAPPED[folder],
|
||||
"the operator's map already states this id"])
|
||||
tally["MAPPED"] += 1
|
||||
continue
|
||||
query, untouched = clean(folder), clean(folder) == folder
|
||||
ys = years(folder)
|
||||
if not query:
|
||||
rows.append([files, "NONE", folder, "", "", "", "",
|
||||
"nothing is left of the folder's name once release junk"
|
||||
" is gone"])
|
||||
else:
|
||||
try:
|
||||
hits = call("/search?query=" + urllib.parse.quote(query)
|
||||
+ "&type=series") or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
hits = []
|
||||
note = f"tvdb did not answer: {e}"
|
||||
want = bare(query)
|
||||
pick = None
|
||||
for h in hits:
|
||||
if bare(h.get("name") or "") != want:
|
||||
continue
|
||||
hy = str(h.get("year") or "")
|
||||
if ys and hy.isdigit() and not (min(ys) <= int(hy) <= max(ys)):
|
||||
continue
|
||||
pick = h
|
||||
break
|
||||
if pick:
|
||||
tag = "EXACT" if untouched else "PUNCT"
|
||||
rows.append([files, tag, folder, query, pick.get("name", ""),
|
||||
str(pick.get("year", "") or ""),
|
||||
str(pick.get("tvdb_id", "") or ""), ""])
|
||||
elif hits:
|
||||
best = hits[0]
|
||||
rows.append([files, "NEAR", folder, query, best.get("name", ""),
|
||||
str(best.get("year", "") or ""),
|
||||
str(best.get("tvdb_id", "") or ""),
|
||||
f"{len(hits)} answer(s); tvdb's own name is not the "
|
||||
"query, so nothing would take it"])
|
||||
else:
|
||||
rows.append([files, "NONE", folder, query, "", "", "",
|
||||
note or "tvdb has no series by that name"])
|
||||
tally[rows[-1][1]] += 1
|
||||
time.sleep(0.12)
|
||||
|
||||
# Two folders mapping to one record is not two shows; it is one show the release
|
||||
# folders split. Say it on every row involved, or the operator approves the same
|
||||
# id twice and reads the second as a mistake.
|
||||
by_id = collections.Counter(r[6] for r in rows if r[6] and r[1] != "MAPPED")
|
||||
for r in rows:
|
||||
if r[6] and r[1] != "MAPPED" and by_id[r[6]] > 1:
|
||||
r[7] = (f"MERGE: {by_id[r[6]]} folders map to tvdb {r[6]} — one show, "
|
||||
"the releases split it; " + r[7]).strip("; ")
|
||||
|
||||
with open(ARGS.out, "w") as fh:
|
||||
fh.write("files\tverdict\tfolder\tquery\ttvdb_name\ttvdb_year\ttvdb_id\tnote\n")
|
||||
for r in rows:
|
||||
fh.write("\t".join(str(c) for c in r) + "\n")
|
||||
print(f"sheet: {len(rows)} folders"
|
||||
+ "".join(f" {t}={n}" for t, n in sorted(tally.items()))
|
||||
+ f" -> {ARGS.out}")
|
||||
Executable
+1258
File diff suppressed because it is too large.
Load diff
Executable
+579
@@ -0,0 +1,579 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""The fleet's release-filename title parser: the grouping authority the relayout
|
||||
plan uses for every file the media server never matched.
|
||||
|
||||
report: media-titles.py report [--probed TSV] [--out TSV]
|
||||
the similar-copies census (DUP / REVIEW / EP-DUP groups) over the
|
||||
ffprobe census scripts/media-census.py writes.
|
||||
parse: media-titles.py parse NAME [NAME…]
|
||||
what title_of() makes of a name — the way to check a naming accident
|
||||
before it merges two films into one folder.
|
||||
|
||||
scripts/media-plan.py imports this file; over there the paths are arguments.
|
||||
|
||||
Method — three independent signals, so a naming accident cannot merge two films:
|
||||
* franchise KEY : first two significant title tokens. Stable across parts of a
|
||||
collection ("resident evil", "jump street") but NOT film-specific — it only
|
||||
selects candidates.
|
||||
* DURATION : the identity check. Same master encodes differ by at most a
|
||||
couple of seconds; different films / cuts do not collide.
|
||||
* SIZE SPREAD + YEAR: decide the tier. Two files same size AND same duration
|
||||
are byte-twins (the hash pass confirms); same duration but different sizes are
|
||||
two different releases of one film (deletable redundancy).
|
||||
|
||||
TV episodes (SxxEyy in the name) are grouped separately: their uniform runtime
|
||||
would otherwise collapse a whole series into one "duplicate" group.
|
||||
"""
|
||||
import argparse
|
||||
import collections, os, re, sys, unicodedata
|
||||
|
||||
# Library root: the folder directly under this one names the film, and every
|
||||
# folder below it is structure (Extras/, Featurettes/, Disc 1/, BDMV/). Tests pass
|
||||
# their own; nothing here reads media, only the census TSVs about it.
|
||||
ROOT = os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies")
|
||||
|
||||
# Release-tag vocabulary: never part of a title, so the title stops here.
|
||||
JUNK = {"5.1", "7.1", "2.0", "1.0", "6.1", "4.0", "extras"}
|
||||
JUNK_RE = re.compile(
|
||||
r"^("
|
||||
r"\d{3,5}p|\d{1,2}k|uhd|fhd|imax|x ?26[45]|hevc|h ?26[45]|avc|av1|x ?av1|vp[89]"
|
||||
r"|divx|xvid|mpeg-?2?|\d{1,2}-?bit|hdr ?10\+?|hdr|dolby ?vision|dvh?|sdr|hybrid"
|
||||
r"|re-?mux|bd-?re-?mux|blu-?ray|bdrip|brrip|bdmux|bd|web-?(rip|dl)?|hdtv|hdrip"
|
||||
r"|dvd-?(rip|r|scr)?|hdcam|telesync|screener|ppv|re-?encode|extras?"
|
||||
r"|ddp? ?\d(\.\d)?|dd\d(\.\d)?|dts-?hd(\.ma)?|dts|truehd|atmos|e-?ac-?3?|ac-?3"
|
||||
r"|aac\d?(\.\d)?|lpcm|flac|opus|mp3|pcm|\d\.\d(\.\d)?|5 ?1|7 ?1|2 ?0|1 ?0|6 ?1|4 ?0"
|
||||
r"|subs?-?(it|en|eng|ita)?|subbed|dual(-?audio)?|multi|dub(bed)?"
|
||||
r"|ita|eng|en|it|de|fr|es|pl|jp|jap|lat|esp|ger|fre|rus|zho|hin|tam|tel"
|
||||
r"|repack|proper|internal|limited|unrated|extended|director'?s|cut|remaster(ed)?|uncut"
|
||||
r"|special|edition|version|crc32|sha1|sfv|par2|nfo|nzb|mux|complete|unpack"
|
||||
r"|certified|scene|p2p|part\d*|cd\d+|disc\d+|disk\d+|vob|idx|srt|ass|ssa|sup"
|
||||
r"|m3u|cue|log|txt|mkv|mp4|avi|imdb\d[\d.]*|yts(\.\w+)?|rarbg|collection"
|
||||
r")$", re.I)
|
||||
RES_RE = re.compile(r"^\d{3,5}p$|^\d{1,2}k$|^\d+x\d+$", re.I)
|
||||
YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
||||
SEP_RE = re.compile(r"[._\s\-\[\](){}+!~,;:&@#/*'\"`´|]+")
|
||||
IDX_RE = re.compile(r"^\d{1,3}[a-z]?$")
|
||||
WORD_RE = re.compile(r"[A-Za-z0-9']+$")
|
||||
EP_RE = re.compile(r"(?i)(?<![a-z0-9])(s(?:eason[\s._-]*)?\d{1,2}[\s._-]*e(?:p(?:isode)?[\s._-]*)?"
|
||||
r"\d{1,3}|s\d{1,2}\s?e\d{1,3}|\d{1,2}x\d{2,3}|part[\s._-]*\d{1,2})")
|
||||
STOP = {"a", "an", "the", "il", "la", "le", "i", "un", "une", "el", "los", "las", "de",
|
||||
"del", "du", "di", "e", "ed", "y", "and", "et", "for", "of", "to", "in", "on",
|
||||
"vs", "avec", "con"}
|
||||
|
||||
|
||||
def norm(s):
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
return "".join(c for c in s if not unicodedata.combining(c)).lower().strip()
|
||||
|
||||
|
||||
def fold_sep(s):
|
||||
"""norm(), plus: every character that is left over is not a letter, digit or
|
||||
apostrophe, so it is a SEPARATOR whatever codepoint it hides behind — not part
|
||||
of the word. Release groups file titles with lookalikes for punctuation the
|
||||
filesystem forbids: "V ∕ H ∕ S ∕ 2" carries U+2215 DIVISION SLASH, "S꞉ Viral"
|
||||
U+A789, and en dashes hide in "Star Wars – A New Hope". NFKD does NOT fold
|
||||
those (no decomposition mapping), and the scrubber inside title_of DELETES
|
||||
what it does not recognise, so five different V/H/S films squashed into the one
|
||||
title "vhs2" and would have shared one folder. Deleting is the wrong operation:
|
||||
they separate. Accents are gone by then — norm() removed the combining marks —
|
||||
so "Amélie" stays "amelie" and never splits into "ame lie"."""
|
||||
return " ".join("".join(c if c.isalnum() or c == "'" else " "
|
||||
for c in norm(s)).split())
|
||||
|
||||
def title_of(path):
|
||||
"""(title, year) from the basename.
|
||||
|
||||
Two passes. (1) Tokenize; every 4-digit year becomes a sentinel in place.
|
||||
(2) Find the boundary: the first release-noise token (resolution / codec /
|
||||
source / audio / language / group vocabulary) ends the title, so trailing
|
||||
tags can never leak in. (3) Collect up to that boundary: leading index
|
||||
numbers ("03."), leading year sentinels ("1940 - Fantasia") and leading noise
|
||||
are skipped while nothing is collected; once a real word appears a sentinel
|
||||
CLOSES the title. A title sitting BEFORE the year sentinel ("300.2006.…" or
|
||||
"12. Monkeys. 1995") is a numeric title and is prepended to what follows.
|
||||
"""
|
||||
# fold_sep BEFORE tokenizing, so a lookalike separator separates (see its
|
||||
# docstring): the tokens below are then plain alnum/apostrophe words.
|
||||
name = fold_sep(os.path.splitext(os.path.basename(path))[0])
|
||||
raw = [x for x in SEP_RE.split(name) if x]
|
||||
years = [t for t in raw if YEAR_RE.match(t)]
|
||||
two_year = len(years) > 1 # "2001…1968": one of them is the title
|
||||
toks, year = [], None
|
||||
for t in raw:
|
||||
if YEAR_RE.match(t):
|
||||
year = t # last year wins
|
||||
toks.append("\x00")
|
||||
else:
|
||||
toks.append(t)
|
||||
stop, seen_word = len(toks), False
|
||||
for i, t in enumerate(toks):
|
||||
if t == "\x00":
|
||||
continue
|
||||
c = norm(re.sub(r"[^A-Za-z0-9']", "", t))
|
||||
if (not c or RES_RE.match(c) or JUNK_RE.match(c) or c in JUNK
|
||||
or not WORD_RE.match(c)) and not (IDX_RE.match(t) and not seen_word):
|
||||
stop = i
|
||||
break
|
||||
if c.isalpha():
|
||||
seen_word = True
|
||||
# A leading number is a track index ("01. Bye Bye Baby", "05. Casablanca") unless
|
||||
# it is three digits or more ("007", "000", "1408") or the name carries no year
|
||||
# at all ("12 Monkeys", "831"). A 1-2 digit prefix beside a year is an index;
|
||||
# the rare real title that looks like one ("0 Things I Hate About You (1999)")
|
||||
# loses its zero to the parser — Plex's matched title overrides it anyway.
|
||||
lead = re.split(r"[\s._-]", name, maxsplit=1)[0]
|
||||
track_idx = bool(re.fullmatch(r"\d{1,2}[a-z]?", lead)) and bool(year)
|
||||
keep, num_title = [], None
|
||||
for t in toks[:stop]:
|
||||
if t == "\x00":
|
||||
if keep:
|
||||
break # year closes the title
|
||||
if two_year and t is not None and years[0] != year:
|
||||
# two DIFFERENT years ("2001…1968"): the leading one is the title;
|
||||
# "1940 - Fantasia (1940)" repeats one year, so it is not a title
|
||||
num_title = num_title or years[0]
|
||||
continue # leading year: not the title
|
||||
if track_idx and IDX_RE.match(t) and not keep and t == lead:
|
||||
continue # "03." / "1c." ordering
|
||||
c = norm(re.sub(r"[^A-Za-z0-9']", "", t))
|
||||
if not keep and not c.isalpha():
|
||||
num_title = num_title or (c or None)
|
||||
continue # numeric title candidate
|
||||
keep.append(c)
|
||||
if len(keep) >= 8:
|
||||
break
|
||||
keep = [k for k in keep if not re.fullmatch(r"(disc|disk|cd|dvd|part)\d*", k)]
|
||||
title = " ".join(keep)
|
||||
if not keep:
|
||||
# nothing collected: a pure-numeric leading token is the title even when it
|
||||
# sits where the release year sits ("1917.2019.2160p…"), and "300.2006…" too
|
||||
for t in raw:
|
||||
c = norm(re.sub(r"[^A-Za-z0-9']", "", t))
|
||||
if re.fullmatch(r"(?:[12]\d{3}|\d{1,3})", c) and (c != year or two_year):
|
||||
return c, year or "?" # never "2160p" / "1080": they are not pure digits
|
||||
if (num_title and title and not keep[0].isdigit()
|
||||
and (two_year or not track_idx)):
|
||||
# a leading number that only echoes the release year ("1917.2019.2160p…") is
|
||||
# not a title; one that stands alone ("300.2006…", "1900") is
|
||||
title = f"{num_title} {title}"
|
||||
title = title or num_title or norm(name).replace("\x00", " ").strip()[:40]
|
||||
return title, year or "?"
|
||||
|
||||
|
||||
# Bonus-material vocabulary: names that say nothing about the film, so an extras
|
||||
# file otherwise pairs with an extras file of another movie. "Short", "Spot" and
|
||||
# "Clip" are deliberately absent — they are film titles (Short Circuit, Spot,
|
||||
# Clips) — and the running-time guard in is_extra() carries the rest.
|
||||
SEP_CLS = r"[\s._-]+"
|
||||
|
||||
|
||||
def _bonus_rx(word):
|
||||
return r"(?<![a-z0-9])" + re.sub(r"[ \-]+", lambda _: SEP_CLS, word.lower()) + r"(?![a-z0-9])"
|
||||
|
||||
|
||||
EXTRA_WORDS = [
|
||||
"trailer", "teaser", "featurette", "featurettes", "promo", "promotional",
|
||||
"tv spot", "advert", "commercial", "behind the scenes", "making of",
|
||||
"deleted scene", "deleted scenes", "deleted footage", "gag reel", "blooper",
|
||||
"blooper reel", "bloopers", "outtake", "outtakes", "scene selection", "menu",
|
||||
"menus", "gallery", "still gallery", "stills", "poster", "posters",
|
||||
"montage", "preview", "previews", "epilogue", "visual effects",
|
||||
"music video", "singalong", "karaoke", "trivia", "storyboard", "workprint",
|
||||
"test footage", "screen test", "audition", "cast and crew", "isolated score",
|
||||
"picture in picture", "original score", "session piece", "sample",
|
||||
"watermark",
|
||||
]
|
||||
# Words that describe bonus material which can run LONG (a feature-length
|
||||
# commentary-track cut, a documentary about the film). Trusted only below
|
||||
# LONG_BONUS_MAX, so a 40-minute "commentary" release stays a film.
|
||||
EXTRA_LONG_WORDS = ["interview", "interviews", "commentary", "video archive",
|
||||
"archival", "documentary"]
|
||||
EXTRA_NAME_RE = re.compile(
|
||||
"|".join([_bonus_rx(w) for w in EXTRA_WORDS] + [r"scene[\s._-]?\d{1,3}"]), re.I)
|
||||
EXTRA_LONG_NAME_RE = re.compile(
|
||||
"|".join(_bonus_rx(w) for w in EXTRA_LONG_WORDS), re.I)
|
||||
# Bonus-folder vocabulary. fullmatch = the folder IS an extras subtree; a bare
|
||||
# search = it only mentions extras ("... + Extras" box sets hold feature films).
|
||||
EXTRA_FOLDER_RE = re.compile(
|
||||
r"(?:extras?|bonus(?:[\s._-]material)?|specials?|special[\s._-]featurettes?|"
|
||||
r"featurettes?|deleted(?:[\s._-](?:scenes?|footage))?|trailers?|"
|
||||
r"making[\s._-]of|behind[\s._-]the[\s._-]scenes|shorts?|interviews?|"
|
||||
r"archiv(?:al|es)|video[\s._-]archive)", re.I)
|
||||
VIDEO_SUFFIX = (".mkv", ".mp4", ".avi", ".m4v", ".mov", ".m2ts", ".mpg", ".webm")
|
||||
|
||||
# Anything longer than this is a feature film whatever it is called: a 118-minute
|
||||
# file named "The Menu" or "Trailer" is a movie, not bonus material.
|
||||
SHORT_MAX = 45 * 60
|
||||
|
||||
# Long-shaped bonus vocabulary is only believed below this: a 40-minute
|
||||
# commentary cut is an alternate version of the film, not a featurette.
|
||||
LONG_BONUS_MAX = 25 * 60
|
||||
|
||||
|
||||
def name_words(x):
|
||||
"""Bare words of a file or folder name: no release metadata, no year."""
|
||||
x = os.path.splitext(x)[0] if x.lower().endswith(VIDEO_SUFFIX) else x
|
||||
return [w for w in SEP_RE.split(fold_sep(x))
|
||||
if w and not YEAR_RE.match(w) and not RES_RE.match(w)
|
||||
and not JUNK_RE.match(w) and w not in JUNK]
|
||||
|
||||
|
||||
def mentions(short, long_):
|
||||
"""True when one word list appears in the other, anywhere: a release names
|
||||
the film at the front ("Alien (1979) - Trailer") or in the middle
|
||||
("Movies/Alien (1979)/Extra - Logo.mkv"), so a start-of-string rule fails."""
|
||||
a, b = sorted([short, long_], key=len)
|
||||
if not a:
|
||||
return False
|
||||
return any(b[i:i + len(a)] == a for i in range(len(b) - len(a) + 1))
|
||||
|
||||
|
||||
|
||||
def folder_is_bonus(folder):
|
||||
"""True when a folder name carries bonus vocabulary as WHOLE words: the
|
||||
vocabulary is matched against a padded word string, so "Extras" decides and
|
||||
a word that merely CONTAINS one ("Extraordinary", "The Extras") does not."""
|
||||
return bool(EXTRA_FOLDER_RE.search(" " + " ".join(SEP_RE.split(norm(folder))) + " "))
|
||||
|
||||
|
||||
def is_extra(path, title="", dur=0.0, root=ROOT, matched=False):
|
||||
"""True for bonus material, which must never be counted as a film: a file
|
||||
called "Trailer", "Menu", "Sample" or "Gallery" carries no film identity, so
|
||||
extras otherwise pair with each other across the whole library.
|
||||
|
||||
Evidence, strongest first:
|
||||
* RUNTIME VETOES EVERYTHING. The film "The Menu", the film "Trailer", the
|
||||
franchise "Trailer Park Boys" and "ET — The Extra-Terrestrial" all carry
|
||||
bonus vocabulary and run 100+ minutes: anything over SHORT_MAX is a film —
|
||||
unless it sits in an explicit bonus folder and metadata never matched it.
|
||||
Bonus words that describe long-form material (commentary, documentary,
|
||||
interview) are believed only
|
||||
under LONG_BONUS_MAX, so a feature-length commentary cut stays a film.
|
||||
* The file's OWN name carries bonus vocabulary -> bonus.
|
||||
* ANY folder of the path below the film's own root folder carries bonus
|
||||
vocabulary ("Alien (1979)/Extras/", ".../Featurettes/Deleted Scenes/",
|
||||
"Angel's Egg (1985)/04b. Angel's Egg Extras/") -> bonus. The ROOT folder
|
||||
is exempt because it names the film, and a film may be titled with a
|
||||
bonus word; and a file whose own name IS that folder's film name is the
|
||||
feature, not the bonus ("Ghost in the Shell Movies/Extras/
|
||||
Ghost in the Shell - CM3.mp4" is a bonus, but a whole other film filed
|
||||
there stays a film).
|
||||
Bonus vocabulary is matched as whole words only, so the film
|
||||
"Angel's Egg - Extra - Logo" inside "04b. Angel's Egg Extras" is reached by
|
||||
the folder rule, and "…Extras" as a fragment of a word is never evidence.
|
||||
"""
|
||||
parts = [q for q in path.split("/") if q]
|
||||
if not parts:
|
||||
return False
|
||||
name = (os.path.splitext(parts[-1])[0]
|
||||
if parts[-1].lower().endswith(VIDEO_SUFFIX) else "")
|
||||
# A bonus folder anywhere BELOW the film's own folder decides for its
|
||||
# contents; the film's own folder is the film's name, whatever words it
|
||||
# contains, so it is exempt. It is the first component AFTER ROOT's own
|
||||
# components — those are the mount path, not folders of the library.
|
||||
head = len([q for q in root.split("/") if q]) + 1
|
||||
bonus_folder = False
|
||||
for folder in parts[head:-1]:
|
||||
if folder_is_bonus(folder):
|
||||
# The folder's words are often the film's own name ("Angel's Egg
|
||||
# Extras/"), and then they are not bonus evidence. Only WORDS can name
|
||||
# a film: "extras" is release junk that name_words drops, so a folder
|
||||
# like "01. Extras" reduces to its index — and an index matching an
|
||||
# index ("NCOP 01") is a coincidence, not a title.
|
||||
nw = [w for w in name_words(name) if not w.isdigit()]
|
||||
fw = [w for w in name_words(folder) if not w.isdigit()]
|
||||
bonus_folder = not fw or not mentions(nw, fw)
|
||||
break
|
||||
if dur and dur > SHORT_MAX:
|
||||
# Runtime vetoes vocabulary, with ONE exception: an explicit bonus folder
|
||||
# and no metadata match. A 90-minute "Feature Length Rough Cut" sitting in
|
||||
# a movie's Featurettes/ is a disc extra, and nothing in the library claims
|
||||
# it as a film. Long-form bonus words still describe a film, so a
|
||||
# feature-length commentary cut or documentary keeps its runtime veto.
|
||||
if bonus_folder and not matched and not EXTRA_LONG_NAME_RE.search(name):
|
||||
return True
|
||||
return False
|
||||
if EXTRA_NAME_RE.search(name):
|
||||
return True
|
||||
if dur and dur <= LONG_BONUS_MAX and EXTRA_LONG_NAME_RE.search(name):
|
||||
return True
|
||||
return bonus_folder
|
||||
|
||||
# Release-group watermark clips: a group drops a tiny ad ("ETRG.mp4",
|
||||
# "RARBG.com.mp4") into many different films, so its name carries no film
|
||||
# identity at all and it would otherwise pair with itself across the library.
|
||||
WATERMARK_MAX = 20 * 2**20
|
||||
WATERMARK_RE = re.compile(r"(?i)^(rarbg(\.com)?|etrg|tgx|fbt|cpbg|yify|yts|"
|
||||
r"rarbg\.com|playnomore|imprs|gauntlet|dfx|macmote|"
|
||||
r"psi-axiz|acm|tlf|sa8|ebp|gtv|w4g|don|aAF|enjoy|hd4u)$")
|
||||
|
||||
|
||||
def is_watermark(path, size):
|
||||
"""True for a release group's own watermark video, not a film or an extra."""
|
||||
return (size < WATERMARK_MAX
|
||||
and bool(WATERMARK_RE.match(os.path.splitext(os.path.basename(path))[0])))
|
||||
|
||||
|
||||
|
||||
def key_of(title):
|
||||
"""Franchise key: first two alphabetic tokens (numeric-only title = itself).
|
||||
Deliberately NOT film-specific — it only selects candidates for the duration
|
||||
check, so a two-token collision (two films starting "star trek") cannot by
|
||||
itself create a duplicate group."""
|
||||
toks = [t for t in title.split() if t]
|
||||
words = [t for t in toks if not t.isdigit()]
|
||||
if not words:
|
||||
return " ".join(toks)
|
||||
sig = [t for t in words if t not in STOP] or words
|
||||
return " ".join(sig[:2])
|
||||
|
||||
|
||||
def ep_of(path):
|
||||
"""(series, episode-token) when the basename carries SxxEyy / 1x04 / Part N.
|
||||
|
||||
The series is the name BEFORE the marker. The box-set convention writes
|
||||
"Monk - S05E03 - Mr. Monk and the Big Game", and the episode title that
|
||||
follows the marker is not the show's name: keying on the whole title gave
|
||||
every episode of one series its own group, so a 124-episode box set never
|
||||
reached the size that calls something a series and each episode was filed
|
||||
as its own film. A file named only by its marker has nothing before it, so
|
||||
its whole title stands in."""
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
m = EP_RE.search(name)
|
||||
if not m:
|
||||
return None
|
||||
head = name[:m.start()].strip(" ._\u2013-")
|
||||
series = title_of(head)[0] if head else None
|
||||
return series or title_of(path)[0], norm(m.group(1)).replace(" ", "")
|
||||
|
||||
|
||||
def base_words(title, key):
|
||||
"""Title tokens beyond the franchise key — the FILM identity part.
|
||||
"evil dead rise" under key "evil dead" -> {rise}; "28 days later" -> {}."""
|
||||
kt = set(key.split())
|
||||
return frozenset(t for t in title.split() if t not in kt and t not in STOP
|
||||
and not t.isdigit() and not YEAR_RE.match(t))
|
||||
|
||||
|
||||
def folder_year(path):
|
||||
"""Soft year evidence: a 19xx/20xx token in any parent directory name
|
||||
(collection folders are written both "(2002)" and "[2009]"; a range such as
|
||||
"(1998-2000)" deliberately yields nothing)."""
|
||||
for d in reversed(os.path.dirname(path).split("/")):
|
||||
ys = [y for y in re.split(r"[^0-9]+", d) if YEAR_RE.match(y)]
|
||||
if len(ys) == 1:
|
||||
return ys[0]
|
||||
return None
|
||||
|
||||
|
||||
def same_film(a, b):
|
||||
"""Two files are copies of ONE film only when the film-identity tokens agree
|
||||
(one side may carry none) AND no year evidence contradicts them. The key says
|
||||
the franchise matches; this says the actual film matches — it is what keeps
|
||||
'Evil Dead' (2013) apart from 'Evil Dead Rise' (2023) and the four Austin
|
||||
Powers apart when their runtimes happen to agree."""
|
||||
ba, bb = a["base"], b["base"]
|
||||
if ba and bb and ba != bb:
|
||||
return False
|
||||
ya = a["year"] if a["year"] != "?" else a["soft"]
|
||||
yb = b["year"] if b["year"] != "?" else b["soft"]
|
||||
if ya and yb and ya != yb:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def dur_close(a, b):
|
||||
if a <= 0 or b <= 0:
|
||||
return True # unknown duration: let titles decide
|
||||
return abs(a - b) <= max(2.0, 0.003 * max(a, b))
|
||||
|
||||
|
||||
# Blu-ray folder trees: ffprobe only sees the clip it is pointed at, so each tree
|
||||
# is folded into ONE entry, described by its longest main-feature clip (measured
|
||||
# with ffprobe on the largest BDMV/STREAM file) and sized by the whole tree.
|
||||
BDMV_MAIN = {
|
||||
"Avengers.Age.of.Ultron.2015.2160p.BluRay.HEVC.TrueHD.7.1.Atmos-TERMiNAL":
|
||||
dict(dur=8478.2, vcodec="hevc", res="3840x2160", pix="yuv420p10le", fps=23.976),
|
||||
"Full.Metal.Jacket.1987.2160p.BluRay.HEVC.DTS-HD.MA.5.1-EATDIK":
|
||||
dict(dur=6993.3, vcodec="hevc", res="3840x2160", pix="yuv420p10le", fps=23.976),
|
||||
}
|
||||
|
||||
|
||||
def load(probed):
|
||||
"""Probe rows -> film entries. AppleDouble (._*) sidecars are dropped; each
|
||||
BDMV tree collapses to one entry carrying the tree's total size."""
|
||||
trees = {}
|
||||
rows = []
|
||||
for line in open(probed):
|
||||
f = line.rstrip("\n").split("\t")
|
||||
if len(f) < 12:
|
||||
continue
|
||||
b, p, dur, vc, res, fps, vbit, pix, aud, subs, _d, fmt = f[:12]
|
||||
base = os.path.basename(p)
|
||||
if base.startswith("._") or base == ".DS_Store":
|
||||
continue # macOS sidecar, not media
|
||||
m = re.search(r"/(BDMV|VIDEO_TS)/", p)
|
||||
root = p[:m.start()] if m else None
|
||||
if root or re.search(r"/(CERTIFICATE|AACS|MLPI|CLIPINF|PLAYLIST|BACKUP|REGISTRL)/", p):
|
||||
if not root:
|
||||
continue # disc bookkeeping: folds into its tree, never a film
|
||||
if root:
|
||||
t = trees.setdefault(root, dict(bytes=0, audio=set(), subs=0))
|
||||
t["bytes"] += int(b)
|
||||
t["audio"].update(a for a in aud.split("+") if a)
|
||||
t["subs"] += int(subs or 0)
|
||||
continue
|
||||
try:
|
||||
dur = float(dur)
|
||||
except ValueError:
|
||||
dur = 0.0
|
||||
rows.append(dict(bytes=int(b), path=p, dur=dur, vcodec=vc, res=res, fps=fps,
|
||||
vbit=int(float(vbit or 0)), pix=pix, audio=aud, subs=subs, fmt=fmt))
|
||||
for root, t in trees.items():
|
||||
folder = os.path.basename(root)
|
||||
m = BDMV_MAIN.get(folder, {})
|
||||
rows.append(dict(bytes=t["bytes"], path=root + "/BDMV/index.bdmv",
|
||||
name_src=folder, dur=m.get("dur", 0.0),
|
||||
vcodec=m.get("vcodec", "hevc"), res=m.get("res", "-"),
|
||||
fps=m.get("fps", 0), vbit=0, pix=m.get("pix", "-"),
|
||||
audio="+".join(sorted(t["audio"])), subs=str(t["subs"]),
|
||||
fmt="BDMV tree"))
|
||||
for r in rows:
|
||||
r["title"], r["year"] = title_of(r.get("name_src") or r["path"])
|
||||
r["key"] = key_of(r["title"])
|
||||
r["base"] = base_words(r["title"], r["key"])
|
||||
r["soft"] = folder_year(r["path"])
|
||||
r["ep"] = ep_of(r["path"])
|
||||
return rows
|
||||
|
||||
|
||||
def dur_clusters(items):
|
||||
"""Group files that are the SAME FILM and the SAME MASTER duration. Candidate
|
||||
buckets come from the franchise key; same_film then rejects the different
|
||||
installments whose runtimes happen to agree."""
|
||||
clusters = []
|
||||
for r in sorted(items, key=lambda r: -r["dur"]):
|
||||
for c in clusters:
|
||||
if dur_close(c[0]["dur"], r["dur"]) and same_film(c[0], r):
|
||||
c.append(r)
|
||||
break
|
||||
else:
|
||||
clusters.append([r])
|
||||
return clusters
|
||||
|
||||
|
||||
def tier(groups):
|
||||
"""Split same-film/same-duration clusters: DUP = different releases of one
|
||||
film (real, deletable redundancy); REVIEW = identical size AND duration
|
||||
(byte-twins — the hash pass is the authority) or clashing year evidence."""
|
||||
dup, review = [], []
|
||||
for g in groups:
|
||||
g.sort(key=lambda r: -r["bytes"])
|
||||
years = {(r["year"] if r["year"] != "?" else r["soft"]) for r in g}
|
||||
years.discard(None)
|
||||
spread = (g[0]["bytes"] - g[-1]["bytes"]) / g[0]["bytes"] if g[0]["bytes"] else 0
|
||||
if len(years) > 1 or spread < 0.005:
|
||||
review.append(g)
|
||||
else:
|
||||
dup.append(g)
|
||||
return dup, review
|
||||
|
||||
|
||||
def redun(g):
|
||||
return sum(r["bytes"] for r in sorted(g, key=lambda r: -r["bytes"])[1:])
|
||||
|
||||
|
||||
def line(r):
|
||||
return (f"{r['bytes']/2**30:8.2f} GiB {r['res']:>9} {r['vcodec']:<7} "
|
||||
f"{r['pix']:<11} {r['dur']/60:5.0f}m {r['audio'][:34]:<34}")
|
||||
|
||||
|
||||
def dump(tag, groups, out, note=""):
|
||||
for gi, g in enumerate(groups, 1):
|
||||
g.sort(key=lambda r: -r["bytes"])
|
||||
print(f"\n### {tag}-{gi}. {g[0]['title']} ({g[0]['year']}) — {len(g)} files, "
|
||||
f"{redun(g)/2**30:.1f} GiB redundant {note}".rstrip())
|
||||
for rank, r in enumerate(g):
|
||||
print(f" {line(r)} [{r['year']}] "
|
||||
f"{r['path'].replace(ROOT + '/', '')}")
|
||||
out.write(f"{tag}\t{gi}\t{rank}\t{r['bytes']}\t{r['res']}\t{r['vcodec']}\t"
|
||||
f"{r['pix']}\t{int(r['dur'])}\t{r['audio']}\t{r['title']}|{r['year']}\t"
|
||||
f"{r['path']}\n")
|
||||
|
||||
|
||||
def report(probed, out_path):
|
||||
rows = load(probed)
|
||||
for r in rows:
|
||||
r["mark"] = is_watermark(r["path"], r["bytes"])
|
||||
r["extra"] = not r["mark"] and is_extra(r["path"], r["title"], r["dur"])
|
||||
marks = [r for r in rows if r["mark"]]
|
||||
movies = [r for r in rows if not r["ep"] and not r["extra"] and not r["mark"]]
|
||||
episodes = [r for r in rows if r["ep"] and not r["extra"] and not r["mark"]]
|
||||
extras = [r for r in rows if r["extra"]]
|
||||
print(f"census: {len(rows)} video entries = {len(movies)} films + {len(episodes)} "
|
||||
f"TV episodes + {len(extras)} bonus/trailers "
|
||||
f"({sum(r['bytes'] for r in extras)/2**30:.1f} GiB) "
|
||||
f"+ {len(marks)} release-group watermarks")
|
||||
|
||||
out = open(out_path, "w")
|
||||
|
||||
buckets = collections.defaultdict(list)
|
||||
for r in movies:
|
||||
buckets[r["key"]].append(r)
|
||||
cand = {k: v for k, v in buckets.items() if len(v) > 1}
|
||||
clusters = [c for v in cand.values() for c in dur_clusters(v) if len(c) > 1]
|
||||
dup, review = tier(clusters)
|
||||
dup.sort(key=lambda g: -redun(g))
|
||||
review.sort(key=lambda g: -redun(g))
|
||||
dump("DUP", dup, out)
|
||||
dump("REVIEW", review, out, "— same key+duration, sizes near-identical OR years clash: RECHECK")
|
||||
|
||||
|
||||
ebuf = collections.defaultdict(list)
|
||||
for r in episodes:
|
||||
ebuf[(r["ep"][0], r["ep"][1])].append(r)
|
||||
edup, ereview = tier([c for v in ebuf.values() if len(v) > 1
|
||||
for c in dur_clusters(v) if len(c) > 1])
|
||||
edup.sort(key=lambda g: -redun(g))
|
||||
dump("EP-DUP", edup, out)
|
||||
|
||||
shows = collections.Counter()
|
||||
for r in episodes:
|
||||
shows[r["ep"][0]] += 1
|
||||
multi = {s: n for s, n in shows.items() if n > 4}
|
||||
print("\n=== TV-series content inside Movies/ (>4 episodes) ===")
|
||||
for s, n in sorted(multi.items(), key=lambda kv: -kv[1]):
|
||||
print(f" {n:4d} episodes {s}")
|
||||
out.close()
|
||||
|
||||
print(f"\nDUP groups={len(dup)} files={sum(len(g) for g in dup)} "
|
||||
f"redundant={sum(redun(g) for g in dup)/2**40:.2f} TiB", file=sys.stderr)
|
||||
print(f"REVIEW groups={len(review)} "
|
||||
f"redundant={sum(redun(g) for g in review)/2**40:.2f} TiB", file=sys.stderr)
|
||||
print(f"EP-DUP groups={len(edup)} "
|
||||
f"ep-redundant={sum(redun(g) for g in edup)/2**30:.1f} GiB", file=sys.stderr)
|
||||
|
||||
|
||||
def parse(names):
|
||||
for n in names:
|
||||
t, y = title_of(n)
|
||||
print(f"{t!r} ({y})\tkey={key_of(t)!r}\t{n[:70]}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
rep = sub.add_parser("report", help="similar-copy census over a probe TSV")
|
||||
rep.add_argument("--probed", default="/tmp/media-probed.tsv")
|
||||
rep.add_argument("--out", default="/tmp/media-similar.tsv")
|
||||
par = sub.add_parser("parse", help="show the parsed title/year of a name")
|
||||
par.add_argument("names", nargs="+")
|
||||
a = ap.parse_args()
|
||||
if a.cmd == "report":
|
||||
report(a.probed, a.out)
|
||||
else:
|
||||
parse(a.names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""media-tv.py — move TV episodes out of the movie library into a TV library.
|
||||
|
||||
`scripts/media-plan.py` refuses to file TV: it parks episode files in a
|
||||
`_TV needs a TV library - <series>` pile for a human. This is the other half of
|
||||
that refusal. It walks the movie library, groups the files whose own name
|
||||
carries an episode marker by SERIES, and writes a plan that places each series
|
||||
in the shape both Jellyfin and Plex read without help:
|
||||
|
||||
<library>/<Series> (<year>)/Season <NN>/<the file's own name>.<ext>
|
||||
|
||||
It writes a plan and nothing else. Every rename belongs to
|
||||
`scripts/media-apply.py` — the write-ahead journal, the refusal to overwrite,
|
||||
the all-or-nothing preflight, the rollback. Apply it with `--root` set to the
|
||||
share that holds BOTH libraries (`/mnt/entropy`, not `/mnt/entropy/Movies`) so
|
||||
the executor's own path guard still has teeth, and give it its own journal —
|
||||
one journal is one undo, and a TV move must not be welded to a movie relayout:
|
||||
|
||||
python3 scripts/media-tv.py --root /mnt/entropy/Movies \
|
||||
--library /mnt/entropy/TVShows --out /mnt/entropy/media-tv-plan.tsv
|
||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl
|
||||
python3 scripts/media-apply.py verify --plan /mnt/entropy/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl
|
||||
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
||||
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
|
||||
|
||||
Guards, and why each one is here:
|
||||
|
||||
* The same five-file threshold as the planner. An episode-shaped name on one
|
||||
file is not a series — `The Godfather Part 2` is a film — so a smaller group
|
||||
is reported and left exactly where it is.
|
||||
* An episode whose name carries no season number is reported and left, never
|
||||
filed into an invented `Season 01`.
|
||||
* A sidecar that shares an episode's own name (`.srt`, `.nfo`, …) follows the
|
||||
episode; an episode without its subtitles is a half-move.
|
||||
* A group that would claim one destination twice is not resolved here. The
|
||||
executor refuses the whole plan before its first rename, which is the right
|
||||
outcome: nobody but Frank decides which copy of an episode is the keeper.
|
||||
* An episode the destination library already holds at the same byte count is
|
||||
left where it is. The TV library has held a complete Monk box set for years
|
||||
while the movie library carried a second copy of all 124 episodes; a mover
|
||||
that did not look where it was filing would have put both inside one library.
|
||||
* A symlink is never followed and never moved.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def parse_args():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--root", default=os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies"),
|
||||
help="the movie library to empty of TV")
|
||||
ap.add_argument("--library", default="/mnt/entropy/TVShows",
|
||||
help="the TV library to fill (a different share or folder)")
|
||||
ap.add_argument("--out", default=None,
|
||||
help="plan TSV for scripts/media-apply.py"
|
||||
" (default: media-tv-plan.tsv beside --root)")
|
||||
ap.add_argument("--min", type=int, default=5,
|
||||
help="episode files that make something a series")
|
||||
a = ap.parse_args()
|
||||
if not a.out:
|
||||
a.out = os.path.join(os.path.dirname(os.path.abspath(a.root)),
|
||||
"media-tv-plan.tsv")
|
||||
return a
|
||||
|
||||
|
||||
ARGS = parse_args()
|
||||
ENGINE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media-titles.py")
|
||||
spec = importlib.util.spec_from_file_location("eng", ENGINE)
|
||||
eng = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(eng)
|
||||
|
||||
BAD_CHARS = re.compile(r'[<>:"/|?*]')
|
||||
# The season and episode, from the token media-titles.ep_of() hands back: s05e03, 1x04.
|
||||
SEASON_RE = re.compile(r"^(?:s(\d{1,2})e(\d{1,4})|(\d{1,2})x(\d{1,4}))$")
|
||||
# Sidecars that follow an episode; macOS forks and resume data do not.
|
||||
SKIP_SIDECAR = re.compile(r"^(\._|\.DS_|dl_state)")
|
||||
# A box set that stamps the show's year into every file name; the folder's year
|
||||
# must not be added on top of it.
|
||||
YEAR_TAIL = re.compile(r"[(\[]\s*(18|19|20)\d\d\s*[)\]]\s*$")
|
||||
SMALL = {"a", "an", "the", "and", "of", "for", "in", "on", "to", "with", "at",
|
||||
"by", "de", "la", "vs"}
|
||||
|
||||
|
||||
def clean(name):
|
||||
"""A name the filesystem and both matchers can read (same law as the plan)."""
|
||||
name = BAD_CHARS.sub("-", name).replace("\\", "-")
|
||||
name = re.sub(r"\s+", " ", name).strip().strip(". ").strip()
|
||||
return name[:180] or "untitled"
|
||||
|
||||
|
||||
def episode_name(path):
|
||||
"""The file's own name, kept: the publisher's spelling beats a re-derivation.
|
||||
|
||||
A dot-separated release name (`monk.s05e03.mkv`) becomes words; a name that
|
||||
already carries spaces is left exactly as it was written."""
|
||||
stem, ext = os.path.splitext(os.path.basename(path))
|
||||
if " " not in stem and stem.count(".") >= 2:
|
||||
stem = stem.replace(".", " ")
|
||||
return clean(stem) + ext.lower()
|
||||
|
||||
|
||||
def head_of(path):
|
||||
"""The publisher's series name: the text BEFORE the episode marker.
|
||||
|
||||
`Monk - S05E03 - Mr. Monk and the Big Game` is `Monk`; the episode title
|
||||
after the marker is not the show's name, and case is kept because it is the
|
||||
library's own spelling, not a normalised key."""
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
m = eng.EP_RE.search(name)
|
||||
head = name[:m.start()] if m else name
|
||||
return re.sub(r"\s+", " ", re.sub(r"[._]+", " ", head)).strip(" -\u2013").strip()
|
||||
|
||||
|
||||
def title_series(head):
|
||||
"""The series folder in the case a TV library is written in.
|
||||
|
||||
Only a name with no capital at all is retitled: a box set that was
|
||||
lower-cased on the way in would otherwise file `monk (2002)` among
|
||||
`Dexter's Laboratory (1996)`. A name the publisher wrote with case is left
|
||||
exactly as it was written."""
|
||||
if any(c.isupper() for c in head):
|
||||
return head # the publisher wrote it with case; keep it
|
||||
words = head.split()
|
||||
return " ".join(w if i and w.lower() in SMALL else w.capitalize()
|
||||
for i, w in enumerate(words))
|
||||
|
||||
|
||||
def season_episode(token):
|
||||
"""(season, episode) an episode token states, or None when it states none."""
|
||||
m = SEASON_RE.match(token)
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1) or m.group(3)), int(m.group(2) or m.group(4))
|
||||
|
||||
|
||||
def sidecars(path):
|
||||
"""The files beside PATH that carry its exact stem — its subtitles, its nfo."""
|
||||
d, stem = os.path.split(path)
|
||||
stem = os.path.splitext(stem)[0]
|
||||
out = []
|
||||
try:
|
||||
entries = sorted(os.listdir(d))
|
||||
except OSError:
|
||||
return out
|
||||
for e in entries:
|
||||
p = os.path.join(d, e)
|
||||
if p == path or os.path.islink(p) or not os.path.isfile(p):
|
||||
continue
|
||||
s, _ext = os.path.splitext(e)
|
||||
if s == stem and not SKIP_SIDECAR.match(e):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def episodes_under(root):
|
||||
"""(path, series, token) for every episode-shaped video file under root."""
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = sorted(d for d in dirnames
|
||||
if not d.startswith(".") and d != "@eaDir")
|
||||
for f in filenames:
|
||||
p = os.path.join(dirpath, f)
|
||||
if os.path.islink(p) or not f.lower().endswith(eng.VIDEO_SUFFIX):
|
||||
continue
|
||||
e = eng.ep_of(p)
|
||||
if e:
|
||||
yield p, e[0], e[1]
|
||||
|
||||
|
||||
def walk(root, library):
|
||||
"""Episode files under ROOT keyed by series, ignoring the destination."""
|
||||
groups = collections.defaultdict(list)
|
||||
lib = os.path.abspath(library)
|
||||
for p, series, token in episodes_under(root):
|
||||
if os.path.abspath(p).startswith(lib + os.sep):
|
||||
continue
|
||||
groups[series].append((p, token))
|
||||
return groups
|
||||
|
||||
|
||||
def holdings(library):
|
||||
"""What the destination already carries: (series, season, episode) -> sizes.
|
||||
|
||||
The one expensive read here, and the one that matters. The movie library
|
||||
holds a complete Monk box set that the TV library has held for years: 124
|
||||
episodes, the same episode list, identical byte counts. Moving it would
|
||||
have filed a second copy of a whole show into the library that already had
|
||||
it. Byte count is the test on purpose — two encodes of one episode are two
|
||||
files and both are wanted; the same bytes are the same file."""
|
||||
out = collections.defaultdict(set)
|
||||
for p, series, token in episodes_under(library):
|
||||
se = season_episode(token)
|
||||
if se is None:
|
||||
continue
|
||||
try:
|
||||
out[(series, *se)].add(os.path.getsize(p))
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
root, library = os.path.abspath(ARGS.root), os.path.abspath(ARGS.library)
|
||||
groups = walk(root, library)
|
||||
held_here = holdings(library)
|
||||
mkdirs = {library}
|
||||
rows, lines, skipped = set(), [], []
|
||||
for series, items in sorted(groups.items()):
|
||||
if len(items) < ARGS.min:
|
||||
skipped.append(f"skipped: {head_of(items[0][0]) or series} — {len(items)} "
|
||||
f"episode-shaped file{'s' if len(items) != 1 else ''},"
|
||||
f" below {ARGS.min}: not a series")
|
||||
continue
|
||||
years = collections.Counter(y for y in (eng.folder_year(p) for p, _t in items)
|
||||
if y)
|
||||
year = years.most_common(1)[0][0] if years else None
|
||||
heads = collections.Counter(head_of(p) for p, _t in items)
|
||||
head = heads.most_common(1)[0][0]
|
||||
if year and YEAR_TAIL.search(head):
|
||||
year = None
|
||||
name = clean(f"{title_series(head)}{f' ({year})' if year else ''}")
|
||||
series_dir = os.path.join(library, name)
|
||||
moved = held = 0
|
||||
for path, token in items:
|
||||
se = season_episode(token)
|
||||
if se is None:
|
||||
skipped.append(f"skipped: {path} — no season number in its name,"
|
||||
" nothing here invents one")
|
||||
continue
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
continue
|
||||
if size in held_here.get((series, *se), ()):
|
||||
held += 1
|
||||
continue
|
||||
dst_dir = os.path.join(series_dir, f"Season {se[0]:02d}")
|
||||
mkdirs.update([series_dir, dst_dir])
|
||||
base = episode_name(path)
|
||||
rows.add((path, os.path.join(dst_dir, base)))
|
||||
moved += 1
|
||||
for side in sidecars(path):
|
||||
rows.add((side, os.path.join(
|
||||
dst_dir, clean(os.path.splitext(base)[0]
|
||||
+ os.path.splitext(side)[1].lower()))))
|
||||
if moved:
|
||||
lines.append(f"{name}: {moved} episodes"
|
||||
f"{'' if year else ' (no year in any folder name)'}"
|
||||
f" -> {series_dir}")
|
||||
if held:
|
||||
lines.append(f"{name}: {held}"
|
||||
f" {'episode' if held == 1 else 'episodes'} already in"
|
||||
f" {library} at the same byte count — left there")
|
||||
|
||||
# An empty plan creates nothing, not even the library folder: a run that
|
||||
# found no series must not be able to leave a stray directory behind.
|
||||
dirs = sorted(mkdirs, key=lambda p: (p.count(os.sep), p)) if rows else []
|
||||
with open(ARGS.out, "w") as out:
|
||||
out.write("action\tsrc\tdst\tkind\n")
|
||||
for d in dirs:
|
||||
out.write(f"mkdir\t\t{d}\tdir\n")
|
||||
for src, dst in sorted(rows):
|
||||
out.write(f"move\t{src}\t{dst}\ttv\n")
|
||||
|
||||
for line in lines + skipped:
|
||||
print(line)
|
||||
print(f"plan: {len(rows)} moves, {len(dirs)} folders -> {ARGS.out}")
|
||||
if rows:
|
||||
print(f"next: python3 scripts/media-apply.py apply --plan {ARGS.out}"
|
||||
f" --root {os.path.dirname(root)}"
|
||||
f" --journal {os.path.join(os.path.dirname(root),'media-relayout-tv.jsonl')}"
|
||||
" (add --apply to run it)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prove a relayout landed, using the media server's own tables.
|
||||
|
||||
A scan answers `204` whether or not it found anything, and a rename that the
|
||||
scanner has not followed looks exactly like a rename that never happened from
|
||||
the outside. The only honest proof is the run's journal replayed against the
|
||||
database the server actually serves from: every `move` row must have a row at
|
||||
its destination, and NO row may still name its source — a source row is a scan
|
||||
that has not caught up, and it is the one signature of a library pointing at
|
||||
files that no longer exist.
|
||||
|
||||
Two doors, both read-only, both without a lock while the server runs (there is
|
||||
no `sqlite3` binary on these hosts). Open `mode=ro` PLAIN: both servers keep
|
||||
their libraries in WAL mode, and `immutable=1` — which claims the file never
|
||||
changes — makes sqlite skip the write-ahead log entirely, so every row the
|
||||
server has committed but not yet checkpointed reads as MISSING, and a read
|
||||
beside a running scanner can even fail as "database disk image is malformed".
|
||||
`immutable=1` is only the fallback, for a database whose `-shm` cannot be read.
|
||||
|
||||
jellyfin `BaseItems.Path`. The container binds the share at `/data`, so the
|
||||
database holds `/data/<library>/…` where the journal holds
|
||||
`/mnt/entropy/<library>/…`. Translate, or every row reads as missing.
|
||||
plex `media_parts.file`, joined up to `metadata_items`. Plex is bound at
|
||||
the host path, so its rows match the journal verbatim. Filter
|
||||
`deleted_at is null` on BOTH tables — a deleted scan leaves its parts
|
||||
behind, and one of those rows is not proof anything landed.
|
||||
|
||||
Neither server holds a row for everything the journal renames: a `.log`, `.cue`
|
||||
or cover image has no row to land in, so its absence is not a fault. The report
|
||||
therefore breaks the missing rows down by extension, and `--ignore-ext` names the
|
||||
extensions this server does not index, which are then counted separately and kept
|
||||
out of the verdict. What is left is split by what the filesystem says, because
|
||||
the database alone cannot: a file still at its `src` is `REAL` (this pass's own
|
||||
damage), a file at neither path is `gone` (a later pass or the server renamed it),
|
||||
and a file sitting at its `dst` with no row naming it is simply something the
|
||||
server does not index. A row that still names `src` is none of these: it is the
|
||||
server's own stale pointer — a phantom — and it costs a re-scan, never a restore.
|
||||
|
||||
Exit codes: 0 every move landed; 1 something is stale or missing (the details are
|
||||
printed); 2 the journal or the database is not usable at all.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
JF_DB = "/home/dank/Containers/jellyfin/config/data/jellyfin.db"
|
||||
PLEX_DB = ("/var/lib/plexmediaserver/Library/Application Support/Plex Media Server"
|
||||
"/Plug-in Support/Databases/com.plexapp.plugins.library.db")
|
||||
|
||||
# Per kind: the table whose columns are required, those columns, and the query
|
||||
# that returns every path the server believes it has.
|
||||
KINDS = {
|
||||
"jellyfin": {
|
||||
"table": "BaseItems",
|
||||
"columns": {"Path"},
|
||||
"sql": "select Path from BaseItems",
|
||||
"default_db": JF_DB,
|
||||
},
|
||||
"plex": {
|
||||
"table": "media_parts",
|
||||
"columns": {"file", "media_item_id", "deleted_at"},
|
||||
"sql": ("select p.file from media_parts p"
|
||||
" join media_items m on m.id = p.media_item_id"
|
||||
" join metadata_items mi on mi.id = m.metadata_item_id"
|
||||
" where p.deleted_at is null and mi.deleted_at is null"),
|
||||
"default_db": PLEX_DB,
|
||||
},
|
||||
}
|
||||
|
||||
def die(msg):
|
||||
print(f"media-verify: {msg}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def read_moves(path):
|
||||
"""The (src, dst) pairs of a journal, in the order the run performed them."""
|
||||
moves = []
|
||||
try:
|
||||
fh = open(path, encoding="utf-8")
|
||||
except OSError as e:
|
||||
die(f"cannot read the journal {path}: {e.strerror}")
|
||||
with fh:
|
||||
for n, line in enumerate(fh, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
die(f"{path} line {n} is not a journal record")
|
||||
if rec.get("op") == "move":
|
||||
if not rec.get("src") or not rec.get("dst"):
|
||||
die(f"{path} line {n} is a move without both src and dst")
|
||||
moves.append((rec["src"], rec["dst"]))
|
||||
return moves
|
||||
|
||||
|
||||
def server_paths(db, kind):
|
||||
"""Every path the server holds, or a refusal that names what is missing."""
|
||||
spec = KINDS[kind]
|
||||
con = None
|
||||
err = None
|
||||
# Plain ro reads the WAL the server commits through; immutable is the last
|
||||
# resort, because it cannot see a row the server has not checkpointed.
|
||||
for opt in ("mode=ro", "mode=ro&immutable=1"):
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db}?{opt}", uri=True)
|
||||
break
|
||||
except sqlite3.Error as e:
|
||||
err = e
|
||||
if con is None:
|
||||
die(f"cannot open {db}: {err}")
|
||||
with con:
|
||||
have = {r[1] for r in con.execute(f"pragma table_info({spec['table']})")}
|
||||
if not spec["columns"] <= have:
|
||||
die(f"{db} is not a {kind} library database ({spec['table']} has no "
|
||||
f"{','.join(sorted(spec['columns'] - have))})")
|
||||
try:
|
||||
return {r[0] for r in con.execute(spec["sql"]) if r[0]}
|
||||
except sqlite3.Error as e:
|
||||
die(f"{db} refused the {kind} query: {e}")
|
||||
|
||||
|
||||
def to_host(path, container, share):
|
||||
"""The database's path in the journal's terms; identity when it is not a
|
||||
container-side path."""
|
||||
if not container:
|
||||
return path
|
||||
prefix = container.rstrip("/") + "/"
|
||||
if path.startswith(prefix):
|
||||
return share.rstrip("/") + "/" + path[len(prefix):]
|
||||
return path
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--journal", required=True,
|
||||
help="the run's journal (scripts/media-apply.py writes it)")
|
||||
ap.add_argument("--db-kind", choices=sorted(KINDS), required=True)
|
||||
ap.add_argument("--db", default=None, help="the server's database on this host")
|
||||
ap.add_argument("--share", default="/mnt/entropy",
|
||||
help="the share every journal path lives under")
|
||||
ap.add_argument("--container", default=None,
|
||||
help="what the share is called inside the container; empty"
|
||||
" compares verbatim (default /data for jellyfin, nothing"
|
||||
" for plex)")
|
||||
ap.add_argument("--show", type=int, default=20,
|
||||
help="how many offenders to name before saying 'and N more'")
|
||||
ap.add_argument("--ignore-ext", default="",
|
||||
help="comma-separated extensions this server does not index,"
|
||||
" kept out of the verdict (e.g. log,cue,nfo,srt)")
|
||||
args = ap.parse_args()
|
||||
|
||||
db = args.db or KINDS[args.db_kind]["default_db"]
|
||||
container = ("/data" if args.db_kind == "jellyfin" else "") \
|
||||
if args.container is None else args.container
|
||||
ignore = set()
|
||||
for ext in args.ignore_ext.split(","):
|
||||
ext = ext.strip().lower()
|
||||
if ext:
|
||||
ignore.add(ext if ext.startswith(".") else "." + ext)
|
||||
|
||||
moves = read_moves(args.journal)
|
||||
stored = {to_host(p, container, args.share) for p in server_paths(db, args.db_kind)}
|
||||
|
||||
landed, stale, missing, ignored = 0, [], [], 0
|
||||
for src, dst in moves:
|
||||
if os.path.splitext(dst)[1].lower() in ignore:
|
||||
ignored += 1
|
||||
continue
|
||||
at_dst, at_src = dst in stored, src in stored
|
||||
if at_dst and not at_src:
|
||||
landed += 1
|
||||
continue
|
||||
if at_src:
|
||||
stale.append(src)
|
||||
if not at_dst:
|
||||
missing.append((src, dst))
|
||||
|
||||
# The database cannot tell a lost file from one it never indexes: both read
|
||||
# as "no row at the destination". Stat both paths, so the report separates
|
||||
# the classes the runbook names — `REAL` is the only one that is this pass's
|
||||
# own damage, and `gone` is a later pass or the server's own rename.
|
||||
on_disk, real, gone = [], [], []
|
||||
for src, dst in missing:
|
||||
if os.path.exists(dst):
|
||||
on_disk.append(dst)
|
||||
elif os.path.exists(src):
|
||||
real.append(src)
|
||||
else:
|
||||
gone.append(dst)
|
||||
|
||||
print(f"{args.db_kind}: {len(moves)} moves checked")
|
||||
print(f"landed: {landed}")
|
||||
print(f"still at src: {len(stale)}")
|
||||
print(f"missing from the DB: {len(missing)}")
|
||||
print(f" on disk at dst, no row names it: {len(on_disk)}")
|
||||
print(f" REAL — file still at src: {len(real)}")
|
||||
print(f" gone — at neither path: {len(gone)}")
|
||||
if ignored:
|
||||
print(f"not indexed (extension ignored): {ignored}")
|
||||
if missing:
|
||||
exts = Counter(os.path.splitext(d)[1].lower() or "(no extension)"
|
||||
for _, d in missing)
|
||||
print("missing by extension: "
|
||||
+ ", ".join(f"{e} {n}" for e, n in sorted(exts.items())))
|
||||
for label, items in (("real", real), ("gone", gone), ("missing", on_disk),
|
||||
("stale", stale)):
|
||||
for path in items[:args.show]:
|
||||
print(f" {label}: {path}")
|
||||
if len(items) > args.show:
|
||||
print(f" {label}: … and {len(items) - args.show} more")
|
||||
return 1 if (stale or missing) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env bash
|
||||
# The SSD-backed metadata view that lets Jellyfin and Plex share one metadata set.
|
||||
#
|
||||
# Both servers read a Kodi-format sidecar (movie.nfo / tvshow.nfo / episode.nfo)
|
||||
# ONLY when it sits next to the media file. The media lives on the RAID6 array at
|
||||
# /mnt/entropy — five spinning disks, deliberately mounted read-only for every
|
||||
# container, and slow to seek for a thousand tiny XML reads. So neither media
|
||||
# server binds the array any more: they bind an OverlayFS merge of it with a
|
||||
# writable layer on the NVMe.
|
||||
#
|
||||
# lowerdir /mnt/entropy RAID6 (ROTA=1) — source of truth, never written
|
||||
# upperdir /var/lib/media-meta/upper NVMe — every sidecar and image byte
|
||||
# workdir /var/lib/media-meta/work NVMe — kernel bookkeeping
|
||||
# Where /var/lib/media-meta/view the merged tree jellyfin and plex bind
|
||||
#
|
||||
# Everything the view needs — layers, bookkeeping, trash, the merged door — lives
|
||||
# under the one directory /var/lib/media-meta, so nothing new appears under /mnt.
|
||||
#
|
||||
# Consequences worth knowing:
|
||||
# * A sidecar write is a few KB onto the SSD; the media bytes never move and the
|
||||
# array is still never written by anything automated.
|
||||
# * Deleting through the view writes a whiteout into the upper layer instead of
|
||||
# deleting from the array. Nothing should delete through the view: deletions
|
||||
# go to .AGENTTRASH on the RAW path, and `reap` clears the resulting ghosts.
|
||||
# * inotify events on the lower layer do not cross the merge, so a file that
|
||||
# arrives on the raw array is invisible to a file-system watcher in the view:
|
||||
# trigger a library refresh (see the Media Relayout page).
|
||||
#
|
||||
# Subcommands
|
||||
# plan [--upper-base D] [--lower D] print the resolved shape, touch nothing
|
||||
# verify prove the live mount IS that shape
|
||||
# install [--upper-base D] create the layers, install+enable+start the unit
|
||||
# reap [--apply] [--upper-base D] [--lower D]
|
||||
# list upper-layer entries whose media is gone
|
||||
# status one line per fact a human wants
|
||||
#
|
||||
# install must run as root (it writes /etc/systemd/system and mounts); the others
|
||||
# are read-only and safe unprivileged.
|
||||
set -euo pipefail
|
||||
|
||||
LOWER=/mnt/entropy
|
||||
UPPER_BASE=/var/lib/media-meta
|
||||
VIEW=$UPPER_BASE/view
|
||||
# systemd names a mount unit after its ESCAPED path ('/' -> '-', a literal '-'
|
||||
# -> '\x2d'), so the unit on the host is var-lib-media\x2dmeta-view.mount. The
|
||||
# repo copy keeps a readable name; install writes it under the escaped one.
|
||||
UNIT='var-lib-media\x2dmeta-view.mount'
|
||||
UNIT_DIR=/etc/systemd/system
|
||||
# The rootless containers write as this host uid (the deploy user). The upper
|
||||
# layer has to accept those writes, so it is owned by them.
|
||||
MEDIA_UID="${MEDIA_VIEW_UID:-1000}"
|
||||
MEDIA_GID="${MEDIA_VIEW_GID:-1000}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
UNIT_SRC="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
|
||||
|
||||
die() {
|
||||
printf 'media-view: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf 'usage: %s plan|verify|install|reap|status [options]\n' "$0" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
[ -n "$cmd" ] || usage
|
||||
shift || true
|
||||
|
||||
upper_base="$UPPER_BASE"
|
||||
lower="$LOWER"
|
||||
apply=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--upper-base) [ $# -ge 2 ] || usage; upper_base="$2"; shift 2 ;;
|
||||
--lower) [ $# -ge 2 ] || usage; lower="$2"; shift 2 ;;
|
||||
--apply) apply=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
upper="$upper_base/upper"
|
||||
work="$upper_base/work"
|
||||
|
||||
# The device backing a path, and whether the kernel calls it rotational.
|
||||
rotational_of() {
|
||||
local src rota
|
||||
src="$(findmnt -no SOURCE --target "$1" 2>/dev/null || true)"
|
||||
[ -n "$src" ] || die "cannot find a block device behind $1"
|
||||
rota="$(lsblk -no ROTA "$src" 2>/dev/null | head -1 || true)"
|
||||
[ -n "$rota" ] || die "cannot read the rotational flag of $src"
|
||||
printf '%s %s\n' "$src" "$rota"
|
||||
}
|
||||
|
||||
require_upper_base() {
|
||||
[ -d "$upper_base" ] \
|
||||
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)"
|
||||
}
|
||||
|
||||
do_plan() {
|
||||
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
|
||||
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
|
||||
}
|
||||
|
||||
do_verify() {
|
||||
local fail=0 fst opts src rota
|
||||
if ! mountpoint -q "$VIEW"; then
|
||||
printf 'refused: %s is not a mountpoint — a plain directory here is a\n' "$VIEW" >&2
|
||||
printf ' library that scans itself empty, not a metadata view.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
fst="$(findmnt -no FSTYPE --mountpoint "$VIEW" 2>/dev/null || true)"
|
||||
if [ "$fst" != overlay ]; then
|
||||
printf 'refused: %s is mounted %s, not overlay\n' "$VIEW" "${fst:-nothing}" >&2
|
||||
fail=1
|
||||
fi
|
||||
opts="$(findmnt -no OPTIONS --mountpoint "$VIEW" 2>/dev/null || true)"
|
||||
local want
|
||||
for want in "lowerdir=$LOWER" "upperdir=$UPPER_BASE/upper" "workdir=$UPPER_BASE/work"; do
|
||||
case ",$opts," in
|
||||
*",${want},"*) ;;
|
||||
*) printf 'refused: %s options lack %s (got: %s)\n' "$VIEW" "$want" "$opts" >&2
|
||||
fail=1 ;;
|
||||
esac
|
||||
done
|
||||
read -r src rota <<<"$(rotational_of "$UPPER_BASE")"
|
||||
if [ "$rota" != 0 ]; then
|
||||
printf 'refused: the upper layer sits on %s which is rotational — metadata\n' "$src" >&2
|
||||
printf ' belongs on the SSD, not on the array.\n' >&2
|
||||
fail=1
|
||||
fi
|
||||
if [ ! -w "$UPPER_BASE/upper" ]; then
|
||||
printf 'refused: %s is not writable\n' "$upper" >&2
|
||||
fail=1
|
||||
fi
|
||||
[ "$fail" -eq 0 ] || return 1
|
||||
printf 'view ok: %s <- overlay(%s,%s)\n' "$VIEW" "$LOWER" "$UPPER_BASE/upper"
|
||||
}
|
||||
|
||||
do_install() {
|
||||
[ "$(id -u)" -eq 0 ] || die "install must run as root (writes $UNIT_DIR and mounts)"
|
||||
require_upper_base
|
||||
mountpoint -q "$LOWER" \
|
||||
|| die "refused: $LOWER is not mounted; overlaying an empty directory would present an empty library"
|
||||
local src rota
|
||||
read -r src rota <<<"$(rotational_of "$upper_base")"
|
||||
[ "$rota" = 0 ] || die "refused: $upper_base is on $src, a rotational device — the metadata layer must be on the SSD"
|
||||
[ -f "$UNIT_SRC" ] || die "refused: $UNIT_SRC is missing from $REPO_ROOT"
|
||||
# systemd will not create a mount point; a missing directory fails the start.
|
||||
install -d -m 0755 -o "$MEDIA_UID" -g "$MEDIA_GID" "$upper" "$work" "$VIEW"
|
||||
install -d -m 0700 "$upper_base/.AGENTTRASH"
|
||||
install -m 0644 "$UNIT_SRC" "$UNIT_DIR/$UNIT"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$UNIT" >/dev/null 2>&1 || true
|
||||
systemctl start "$UNIT"
|
||||
do_verify
|
||||
}
|
||||
|
||||
# An upper-layer DIRECTORY with no counterpart below is metadata for a media
|
||||
# folder that has been moved or renamed on the RAW path — a ghost the media
|
||||
# servers would still browse. Files are never candidates on their own: a sidecar
|
||||
# (movie.nfo, an .srt) exists ONLY above, by design, and is exactly what the view
|
||||
# is for. A copy-up whose lower file was deleted by hand is the one case this
|
||||
# leaves in place; the raw path is where deletions belong, so it stays visible.
|
||||
do_reap() {
|
||||
[ -d "$upper" ] || die "refused: $upper does not exist (no upper layer at $upper_base)"
|
||||
stamp="$upper_base/.AGENTTRASH/$(date -u +%Y%m%d-%H%M%S)"
|
||||
count=0
|
||||
ghosts=0
|
||||
reap_walk ""
|
||||
if [ "$count" -eq 0 ] && [ "$ghosts" -eq 0 ]; then
|
||||
printf 'reap: no ghosts under %s\n' "$upper"
|
||||
return 0
|
||||
fi
|
||||
if [ "$ghosts" -gt 0 ]; then
|
||||
printf 'reap: %d whiteout%s above — someone deleted THROUGH the view.\n' \
|
||||
"$ghosts" "$([ "$ghosts" -eq 1 ] || printf s)" >&2
|
||||
printf ' Leaving them: removing a whiteout un-hides the file it hides.\n' >&2
|
||||
printf ' Deletions belong in .AGENTTRASH on the raw path.\n' >&2
|
||||
fi
|
||||
if [ "$count" -eq 0 ]; then return 0; fi
|
||||
if [ "$apply" -eq 1 ]; then
|
||||
printf 'reap: moved %d entr%s to %s\n' "$count" \
|
||||
"$([ "$count" -eq 1 ] && printf y || printf ies)" "$stamp"
|
||||
else
|
||||
printf 'reap: %d ghost entr%s above; re-run with --apply to move them to %s\n' \
|
||||
"$count" "$([ "$count" -eq 1 ] && printf y || printf ies)" "$stamp"
|
||||
fi
|
||||
}
|
||||
|
||||
# Descend only where the lower layer still has the directory: a directory that is
|
||||
# missing below is itself the ghost, and its whole subtree goes with it.
|
||||
reap_walk() {
|
||||
local rel="$1" entries abs r name
|
||||
entries="$(find "$upper${rel:+/$rel}" -mindepth 1 -maxdepth 1 -print 2>/dev/null || true)"
|
||||
[ -n "$entries" ] || return 0
|
||||
while IFS= read -r abs; do
|
||||
r="${abs#"$upper"/}"
|
||||
name="${abs##*/}"
|
||||
if [ -c "$abs" ] || printf '%s' "$name" | grep -q '^\.wh\.'; then
|
||||
ghosts=$((ghosts + 1))
|
||||
continue
|
||||
fi
|
||||
if [ -e "$lower/$r" ]; then
|
||||
[ -d "$abs" ] && reap_walk "$r"
|
||||
continue
|
||||
fi
|
||||
[ -d "$abs" ] || continue
|
||||
count=$((count + 1))
|
||||
printf '%s\n' "$r"
|
||||
if [ "$apply" -eq 1 ]; then
|
||||
mkdir -p "$stamp/$(dirname "$r")"
|
||||
mv "$abs" "$stamp/$r"
|
||||
fi
|
||||
done <<<"$entries"
|
||||
}
|
||||
|
||||
do_status() {
|
||||
if mountpoint -q "$VIEW"; then
|
||||
printf 'view: %s (%s)\n' "$VIEW" "$(findmnt -no OPTIONS --mountpoint "$VIEW")"
|
||||
else
|
||||
printf 'view: %s NOT MOUNTED\n' "$VIEW"
|
||||
fi
|
||||
if [ -d "$upper" ]; then
|
||||
printf 'upper: %s %s files %s\n' "$upper" \
|
||||
"$(find "$upper" -type f 2>/dev/null | wc -l)" "$(du -sh "$upper" 2>/dev/null | cut -f1)"
|
||||
else
|
||||
printf 'upper: %s missing\n' "$upper"
|
||||
fi
|
||||
systemctl is-active "$UNIT" 2>/dev/null | sed 's/^/unit: /' || printf 'unit: %s unknown\n' "$UNIT"
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
plan) do_plan ;;
|
||||
verify) do_verify ;;
|
||||
install) do_install ;;
|
||||
reap) do_reap ;;
|
||||
status) do_status ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -12,8 +12,13 @@
|
||||
# (2026-10-29) the rig's boot died of an OOM, the health loop quietly fell through, and the
|
||||
# pusher fed four rounds of 10M tokens into RESTORED PRODUCTION with nothing recorded:
|
||||
#
|
||||
# 1. the runner must still be alive and its container must answer /health, or this script
|
||||
# kills it, restores the service and exits non-zero — no push, ever;
|
||||
# 1. the runner must still be alive and its container must answer /health AND own the
|
||||
# listening socket, or this script kills it, restores the service and exits non-zero —
|
||||
# no push, ever. The socket clause is not decoration: the runner stops frank-archon
|
||||
# first, the dying process keeps its listener for a moment, and a rig container that
|
||||
# is still loading has not bound the port yet, so `podman inspect` + `/health` both
|
||||
# report success for the WRONG process (measured 2026-10-30: `health ok waited=0s`,
|
||||
# then the pusher refused on the container name and the window died at second zero);
|
||||
# 2. the pusher refuses on its own unless the rig wrote its marker into the tap dir, the
|
||||
# marker names this container, and the container is running (scripts/calib-push.py
|
||||
# --tap-marker), so a second lock survives a mistake in this file;
|
||||
@@ -35,20 +40,26 @@ MODELS="${FRANK_QWEN38_MODELS:-/home/dank/models}"
|
||||
CAMPAIGN="${FRANK_QWEN38_CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}"
|
||||
RAD="${FRANK_QWEN38_RAD:-$MODELS/rad/qwen38-bf16.rad}"
|
||||
CORPUS="${Q38_WINDOW_CALIB_CORPUS:-/home/dank/corpora/out/calib-7168.jsonl}"
|
||||
KLDREF="$CAMPAIGN/kld-ref/kld.json"
|
||||
CALIB="$CAMPAIGN/calib"
|
||||
CALIB="${FRANK_QWEN38_CALIB_DIR:-$CAMPAIGN/calib}"
|
||||
HEALTH_TIMEOUT="${Q38_WINDOW_HEALTH_TIMEOUT:-3600}"
|
||||
ROUNDS="${Q38_WINDOW_ROUNDS:-6}"
|
||||
RIG=qwen38-bf16
|
||||
SERVICE=frank-archon
|
||||
PY="${PY:-python3}"
|
||||
|
||||
# ---- preflight: everything this window needs, before a card is touched -------
|
||||
[ -f "$KLDREF" ] || { echo "REFUSED: no reference recording at $KLDREF — record it first (scripts/run-qwen38-bf16.sh kldref); this window only fills the calibration tap" >&2; exit 2; }
|
||||
# ---- preflight: every fact by identity, one owner (scripts/qwen38-preflight.sh) ----
|
||||
# The checks that used to live here were proxies, and each lied at least once: the space check
|
||||
# asked a directory that merely LOOKED related, `/health` answered for a dying production
|
||||
# process, `podman inspect` named a container that existed but had bound nothing. The shared
|
||||
# script binds each fact to its object — cwd, the digest the tag actually resolves to, free
|
||||
# space on the tap's own filesystem, every /dev/kfd holder's HOST pid against the service's,
|
||||
# the :7770 listener's host pid, the corpus and the reference recording. A resumed window
|
||||
# keeps an existing tap, so the tap-empty fact is waived.
|
||||
[ -f "$RAD" ] || { echo "REFUSED: no BF16 reference container at $RAD — nothing to serve; scripts/build-qwen38-bf16-rad.sh" >&2; exit 2; }
|
||||
[ -f "$CORPUS" ] || { echo "REFUSED: no calibration corpus at $CORPUS" >&2; exit 2; }
|
||||
[ -d "$CALIB" ] || { echo "REFUSED: no tap dir at $CALIB" >&2; exit 2; }
|
||||
[ -w "$CALIB" ] || { echo "REFUSED: tap dir $CALIB is not writable by $(id -un)" >&2; exit 2; }
|
||||
FRANK_QWEN38_CAMPAIGN="$CAMPAIGN" \
|
||||
Q38_WINDOW_CALIB_CORPUS="$CORPUS" \
|
||||
Q38_CALIB_MIN_FREE_MIB="${Q38_CALIB_MIN_FREE_MIB:-61440}" \
|
||||
bash scripts/qwen38-preflight.sh --allow-existing-tap || exit 2
|
||||
! [[ "$HEALTH_TIMEOUT" =~ ^[0-9]+$ ]] && { echo "REFUSED: Q38_WINDOW_HEALTH_TIMEOUT must be seconds (got: $HEALTH_TIMEOUT)" >&2; exit 2; }
|
||||
! [[ "$ROUNDS" =~ ^[0-9]+$ ]] && { echo "REFUSED: Q38_WINDOW_ROUNDS must be a round count (got: $ROUNDS)" >&2; exit 2; }
|
||||
|
||||
@@ -69,8 +80,18 @@ say "phase=calib serve begin"
|
||||
bash scripts/run-qwen38-bf16.sh calib & spid=$!
|
||||
|
||||
rig_up() {
|
||||
podman inspect -f '{{.State.Running}}' "$RIG" 2>/dev/null | grep -qx true \
|
||||
&& curl -sf --max-time 10 -o /dev/null http://127.0.0.1:7770/health
|
||||
podman inspect -f '{{.State.Running}}' "$RIG" 2>/dev/null | grep -qx true || return 1
|
||||
curl -sf --max-time 10 -o /dev/null http://127.0.0.1:7770/health || return 1
|
||||
# The answer must come from the CONTAINER. The runner stops frank-archon first, and the
|
||||
# dying production process keeps its listener for a moment while the freshly created
|
||||
# rig container is still loading — so `container running` + `/health` 200 both hold
|
||||
# before the rig has bound anything. Measured 2026-10-30: `health ok waited=0s`, then the
|
||||
# pusher refused on the container name and the window died at second zero. Ask the kernel
|
||||
# who owns the listening socket and require it to be a rig process.
|
||||
local lpid
|
||||
lpid=$(ss -H -ltnp 'sport = :7770' 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2)
|
||||
[ -n "${lpid:-}" ] || return 1
|
||||
podman top "$RIG" pid 2>/dev/null | grep -qw "$lpid"
|
||||
}
|
||||
|
||||
waited=0
|
||||
@@ -115,7 +136,7 @@ say "TAP BANKED: $(ls -1 "$CALIB" | wc -l) file(s) in $CALIB"
|
||||
if [ "${Q38_WINDOW_KEEP_RAD:-0}" = "1" ]; then
|
||||
say "reference .rad kept (Q38_WINDOW_KEEP_RAD=1)"
|
||||
else
|
||||
T="$HOME/.AGENTTRASH/$(date +%Y%m%d)-qwen38-bf16-rad"
|
||||
T="${Q38_WINDOW_TRASH_ROOT:-$HOME/.AGENTTRASH}/$(date +%Y%m%d)-qwen38-bf16-rad"
|
||||
mkdir -p "$T" || { echo "FAIL: cannot create trash dir $T" >&2; exit 1; }
|
||||
mv "$RAD" "$T/" && say "reference .rad banked in $T (the checkpoint that rebuilds it stays)"
|
||||
fi
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# qwen38-preflight.sh — the facts a calibration window must have BEFORE it takes the cards,
|
||||
# each checked by IDENTITY and not by proxy. Every line is PASS or FAIL with the reason.
|
||||
#
|
||||
# Why this file exists: on 2026-10-30 a window died at second zero because `/health` answered
|
||||
# 200 from the *production* process that was still draining its listener, while the freshly
|
||||
# created rig container had not bound anything. Other runs died from a port that answered for
|
||||
# the wrong process, a /dev/kfd held by an orphaned engine, a tap dir on a 35 GiB filesystem,
|
||||
# and a launcher whose cwd silently lacked the repo. None of those were math problems; all of
|
||||
# them were a check that measured something adjacent to the thing it meant.
|
||||
#
|
||||
# Usage: bash scripts/qwen38-preflight.sh [--allow-existing-tap]
|
||||
# Exit: 0 all PASS · 2 any FAIL · 3 a fact could not be evaluated (missing tool)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SELF_DIR/.." && pwd)"
|
||||
|
||||
CAMPAIGN="${FRANK_QWEN38_CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}"
|
||||
TAP="$CAMPAIGN/calib"
|
||||
CORPUS="${Q38_WINDOW_CALIB_CORPUS:-/home/dank/corpora/out/calib-7168.jsonl}"
|
||||
NEED_MIB="${Q38_CALIB_MIN_FREE_MIB:-61440}"
|
||||
ALLOW_EXISTING=0
|
||||
[ "${1:-}" = "--allow-existing-tap" ] && ALLOW_EXISTING=1
|
||||
|
||||
# The producer pin is the calib image; the window fails closed if the tag on this host is not it.
|
||||
WANT_DIGEST="${Q38_PREFLIGHT_IMAGE_DIGEST:-sha256:69add58f7de1ed2bca2b1e20a4cc75717557df3f73569a178eca5aafe8c44093}"
|
||||
IMAGE_REF="${Q38_CALIB_IMAGE:-stilldeadcode/radiance@$WANT_DIGEST}"
|
||||
SERVICE="frank-archon"
|
||||
PORT=7770
|
||||
|
||||
fails=0
|
||||
ok() { printf 'PASS %-14s %s\n' "$1" "$2"; }
|
||||
bad() { printf 'FAIL %-14s %s\n' "$1" "$2"; fails=$((fails + 1)); }
|
||||
skip() { printf 'WARN %-14s %s\n' "$1" "$2"; }
|
||||
|
||||
# 1. cwd — a detached launcher that lost its cwd fails here in a second, not in the log later.
|
||||
if [ "$(cd "$REPO_ROOT" && pwd)" = "$(pwd)" ] && [ -x "$REPO_ROOT/scripts/run-qwen38-bf16.sh" ]; then
|
||||
ok cwd "running from the repo root ($REPO_ROOT)"
|
||||
else
|
||||
bad cwd "cwd is $(pwd), not the repo root ($REPO_ROOT) — every relative script path would break"
|
||||
fi
|
||||
|
||||
# 2. image — resolve the REF to a digest. A stale local `:latest` is a different model of the world.
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
# An image carries EVERY digest it was pulled under (a tag plus the by-digest pull), so this
|
||||
# asks whether the pin is one of them — reading RepoDigests[0] reports a healthy image as wrong.
|
||||
got="$(podman image inspect -f '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE_REF" 2>/dev/null \
|
||||
| sed 's/.*@//' | grep -c "^${WANT_DIGEST}$")"
|
||||
if [ "${got:-0}" -ge 1 ]; then
|
||||
ok image "$IMAGE_REF carries ${WANT_DIGEST:0:19}…"
|
||||
elif podman image exists "$IMAGE_REF" 2>/dev/null; then
|
||||
bad image "$IMAGE_REF is present but does not carry $WANT_DIGEST (a retagged or older pull)"
|
||||
else
|
||||
bad image "$IMAGE_REF is not present on this host"
|
||||
fi
|
||||
else
|
||||
skip image "podman not on PATH — cannot resolve $IMAGE_REF"
|
||||
fi
|
||||
|
||||
# 3. space — df the FILE PATH the seal writes to, never a directory that merely looks related.
|
||||
if [ -d "$TAP" ] || mkdir -p "$TAP" 2>/dev/null; then
|
||||
avail="$(df --output=avail -B 1M "$TAP" 2>/dev/null | tail -1 | tr -d ' ')"
|
||||
if [[ "${avail:-}" =~ ^[0-9]+$ ]] && [ "$avail" -ge "$NEED_MIB" ]; then
|
||||
ok space "$avail MiB free on $(df --output=target "$TAP" | tail -1) (need $NEED_MIB)"
|
||||
else
|
||||
bad space "${avail:-unknown} MiB free on $TAP, need $NEED_MIB — one seal writes ~43 GB"
|
||||
fi
|
||||
# 4. tap cleanliness — stale grams from another run must never be mixed in.
|
||||
n=$(ls -A "$TAP" 2>/dev/null | grep -c . || true)
|
||||
if [ "$n" -eq 0 ] || [ "$ALLOW_EXISTING" = 1 ]; then
|
||||
ok tap-empty "$n entries in $TAP"
|
||||
else
|
||||
bad tap-empty "$n entries in $TAP — rotate them to .AGENTTRASH first"
|
||||
fi
|
||||
else
|
||||
bad space "cannot create $TAP"
|
||||
fi
|
||||
|
||||
# 5. device — every /dev/kfd holder must be a process of the service we are about to stop. An
|
||||
# orphaned engine from a previous run makes the next boot die with `startup stopped: device
|
||||
# error`. Identity comes from podman's own host-pid list: `podman top <ctr> pid` prints
|
||||
# CONTAINER-internal pids (1, 7, …), which compare against nothing, and a rootless cgroup
|
||||
# carries the container ID, never the service name — both are proxies that lie.
|
||||
PROD_CTR="${Q38_PREFLIGHT_PROD_CONTAINER:-$SERVICE}"
|
||||
service_pids() { podman top "$PROD_CTR" hpid 2>/dev/null | tail -n +2; }
|
||||
if [ -e /dev/kfd ]; then
|
||||
mine="$(service_pids | tr '\n' ' ')"
|
||||
foreign=""
|
||||
for p in $(fuser /dev/kfd 2>/dev/null); do
|
||||
case " $mine " in
|
||||
*" $p "*) : ;; # the service we will stop
|
||||
*) foreign="$foreign $p:$(cat /proc/$p/comm 2>/dev/null)" ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$foreign" ]; then
|
||||
ok device "no foreign /dev/kfd holders"
|
||||
else
|
||||
bad device "foreign holders:$foreign — kill by pid, never by pattern"
|
||||
fi
|
||||
else
|
||||
skip device "/dev/kfd absent — not an AMD host?"
|
||||
fi
|
||||
|
||||
# 6. port — a listener on :7770 must be a process of the service we stop, never an unknown one.
|
||||
lpid="$(ss -H -ltnp "sport = :$PORT" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2)"
|
||||
if [ -z "${lpid:-}" ]; then
|
||||
ok port ":$PORT is free"
|
||||
else
|
||||
mine="$(service_pids | tr '\n' ' ')"
|
||||
case " $mine " in
|
||||
*" $lpid "*) ok port ":$PORT is held by $SERVICE (host pid $lpid) — the window stops it" ;;
|
||||
*) bad port ":$PORT is held by host pid $lpid ($(cat /proc/$lpid/comm 2>/dev/null)), not $SERVICE" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 7. corpus — the push source must exist and be the re-chunked file the window documents.
|
||||
if [ -s "$CORPUS" ]; then
|
||||
ok corpus "$(wc -l < "$CORPUS") prompts in $CORPUS"
|
||||
else
|
||||
bad corpus "$CORPUS is missing or empty"
|
||||
fi
|
||||
|
||||
# 8. reference — the KL gate has nothing to compare against without it.
|
||||
if [ -s "$CAMPAIGN/kld-ref/kld.json" ]; then
|
||||
ok reference "$(du -h "$CAMPAIGN/kld-ref/kld.json" | cut -f1) kld.json present"
|
||||
else
|
||||
bad reference "$CAMPAIGN/kld-ref/kld.json is missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "PREFLIGHT FAILED: $fails fact(s). The window must not take the cards."
|
||||
exit 2
|
||||
fi
|
||||
echo "PREFLIGHT PASSED: every fact checked by identity."
|
||||
@@ -28,10 +28,18 @@
|
||||
# (RADIANCE_CALIB_DIR + RADIANCE_CALIB_TOKENS=12000000). A
|
||||
# separate driver — scripts/calib-push.py --port 7770
|
||||
# --container qwen38-bf16 --corpus
|
||||
# /home/dank/corpora/out/calib.jsonl — pushes the corpus; at
|
||||
# 12M pooled positions the engine writes the Grams and the
|
||||
# tap switches itself off (calib-push prints TAP_DONE). Stop
|
||||
# this script after that; the trap brings frank-archon back.
|
||||
# /home/dank/corpora/out/calib-7168.jsonl — pushes the corpus;
|
||||
# at the floor the engine writes the Grams and the tap switches
|
||||
# itself off (calib-push prints TAP_DONE). Stop this script
|
||||
# after that; the trap brings frank-archon back. THE FLOOR IS
|
||||
# THE CAMPAIGN'S RULE (12 M tokens, Frank 2026-10-30): the
|
||||
# served iq4r this arm competes with was calibrated on a 10 M-
|
||||
# token set, so the arm is calibrated at least as deeply. One
|
||||
# round of the re-chunked corpus is ~9.31 M tokens, so sealing
|
||||
# takes round 1 plus part of round 2 — a round that seals
|
||||
# nothing is NORMAL (the floor is pooled), and the pusher's
|
||||
# round_verdict treats a flat round as "continue", never as a
|
||||
# failure. One seal writes ~40 GiB.
|
||||
#
|
||||
# Shape: the vendor's bf16 calibration lines — expert_tiered placement with the
|
||||
# weights disk tier, deterministic (the resident set pinned to the loader's plan,
|
||||
@@ -113,6 +121,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/frank-archon.env"
|
||||
IMAGE="${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}"
|
||||
# THE CALIBRATION PIN: the LATEST radiance, pinned by digest because a tag is a
|
||||
# moving target and a tap must name the producer that wrote it. `:latest` on
|
||||
# 2026-10-30 resolves to v1.14.0 (revision 4a61d7296bc5), and on that image a
|
||||
# calib boot prints the arming line — "recorded passes: off, the program
|
||||
# accumulates calibration Grams (gram_accum)" — where the production pin
|
||||
# (1.8.1) prints nothing of the kind: a 17-hour run on 1.8.1 wrote ZERO bytes
|
||||
# into the tap dir while /health, /stats and the push all looked healthy
|
||||
# (measured 2026-10-30). The tap code is identical in both trees' sources, so
|
||||
# the shipped 1.8.1 build is what drops it; do not spend another window on it.
|
||||
# Any other image must be named deliberately with --image.
|
||||
CALIB_IMAGE="${Q38_CALIB_IMAGE:-stilldeadcode/radiance@sha256:69add58f7de1ed2bca2b1e20a4cc75717557df3f73569a178eca5aafe8c44093}"
|
||||
IMAGE_EXPLICIT=0
|
||||
|
||||
MODE=""
|
||||
POS=()
|
||||
@@ -122,7 +142,7 @@ while [ $# -gt 0 ]; do
|
||||
kldref|calib|score) MODE="$1" ;;
|
||||
--dry-run) DRY=1 ;;
|
||||
--image) [ "$#" -ge 2 ] || { echo "--image needs a reference" >&2; exit 2; }
|
||||
IMAGE="$2"; shift ;;
|
||||
IMAGE="$2"; IMAGE_EXPLICIT=1; shift ;;
|
||||
*)
|
||||
# score's two positional arguments, and nothing else may be bare.
|
||||
if [ "$MODE" = score ] && [ "${#POS[@]}" -lt 2 ]; then
|
||||
@@ -152,7 +172,7 @@ TEMPLATE="$SCRIPT_DIR/qwen38-froggeric-v22.5.jinja"
|
||||
LOG_DIR="${FRANK_QWEN38_LOG_DIR:-/home/dank/logs}"
|
||||
LOG="$LOG_DIR/qwen38_bf16_${MODE}.log"
|
||||
KLD_REF_DIR="$CAMPAIGN/kld-ref"
|
||||
CALIB_DIR="$CAMPAIGN/calib"
|
||||
CALIB_DIR="${FRANK_QWEN38_CALIB_DIR:-$CAMPAIGN/calib}"
|
||||
C=qwen38-bf16
|
||||
SERVICE=frank-archon
|
||||
# The card split, taken from the file that owns it instead of a private pair:
|
||||
@@ -314,6 +334,21 @@ case "$MODE" in
|
||||
# buys throughput with the expert slab, so price it like any other
|
||||
# VRAM knob. The slots follow frank-archon.env's FRANK_ARCHON_MAX_SEQS
|
||||
# through MAX_SEQS, exactly as kldref and score do.
|
||||
[ "$IMAGE_EXPLICIT" = 1 ] || IMAGE="$CALIB_IMAGE"
|
||||
# ONE TAP SEAL WRITES ~40 GiB. Measured 2026-10-30: a 8,192-token floor wrote
|
||||
# 39.4 GiB into the tap dir within seconds; pointed at a 37 GiB root it filled
|
||||
# the filesystem to zero, after which every write came back short ("calibration
|
||||
# cannot write", "write was short") and the system itself began refusing
|
||||
# writes. The room is demanded up front, on the tap's own filesystem.
|
||||
if [ "$DRY" != 1 ]; then
|
||||
avail_mib=$(df --output=avail -B 1M "$CALIB_DIR" 2>/dev/null | tail -1 | tr -d ' ')
|
||||
[[ "$avail_mib" =~ ^[0-9]+$ ]] || avail_mib=0
|
||||
need_mib="${Q38_CALIB_MIN_FREE_MIB:-61440}"
|
||||
if [ "$avail_mib" -lt "$need_mib" ]; then
|
||||
echo "REFUSED: tap dir $CALIB_DIR has ${avail_mib} MiB free; one seal writes ~40 GiB and this run needs ${need_mib} MiB (Q38_CALIB_MIN_FREE_MIB overrides)" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
ARGS+=(--model "$MODEL_IN" --port 7770
|
||||
--max-num-seqs "$CALIB_SEQS" --max-num-batched-tokens "$CALIB_MNBT")
|
||||
# The tap is only real if this directory exists and the engine can write it:
|
||||
@@ -417,6 +452,19 @@ cp -f "$TEMPLATE" "$TPL" || {
|
||||
# where the service reported `active` while its engine starved on the rig's VRAM.
|
||||
restore() {
|
||||
podman rm -f "$C" >/dev/null 2>&1 || true
|
||||
# Removing the container does NOT kill its engine. Measured 2026-10-30: a rig
|
||||
# radiance kept /dev/kfd after `podman rm -f`, production's
|
||||
# ar_oneshot_2rank_wht6 init then failed `device error`, the engine exited 0,
|
||||
# and Restart=always looped it 100 times with /health never answering. The
|
||||
# service is stopped for the whole run, so any radiance still holding the
|
||||
# device here is an orphan — kill it BY PID (pkill -f matches the caller's
|
||||
# own command line, which is how a session gets killed instead of the orphan).
|
||||
for p in $(fuser /dev/kfd 2>/dev/null); do
|
||||
if [ "$(cat "/proc/$p/comm" 2>/dev/null)" = radiance ]; then
|
||||
echo "restore: killing orphaned radiance pid $p still holding /dev/kfd" >&2
|
||||
kill -9 "$p" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
sudo systemctl start "$SERVICE" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap restore EXIT
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# serve-frank-dragoon.sh — frank-dragoon endpoint: halogen-flash-server 0.16.2
|
||||
# serve-frank-dragoon.sh — frank-dragoon endpoint: halogen-flash-server 0.18.20
|
||||
# (closed-source engine, image ghcr.io/peonist-ai/halogen-flash-server,
|
||||
# digest-pinned in frank-dragoon.env) serving Quat3rnion's NATIVE v2
|
||||
# conversion (2026-10-11) of the abliterated Qwen3.8-Flash-Next on Dankclaw's
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve prism-ml/Ternary-Bonsai-2-9B-gguf (PQ2_0 ternary quant WITH the MTP head
|
||||
# in the file) as frank-zealot on Dankclaw's RTX 3090 via llama.cpp (PrismML
|
||||
# vendor build 10777, CUDA sm_86).
|
||||
# vendor build 12118 (0.7.0-dev, commit 024474448), CUDA sm_86).
|
||||
#
|
||||
# 2026-10-18 (Frank): Bonsai-2 27B PQ2_0 → the 9B MTP export, then the same
|
||||
# evening TWO 262,144-token conversations (--ctx-size 524288 --parallel 2) over
|
||||
@@ -78,7 +78,7 @@ if [[ -f "$ENV_FILE" ]]; then
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/llama-prism-b10777/llama-server}"
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/llama-prism-0244744/bin/llama-server}"
|
||||
: "${MODEL_FILE:=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-PQ2_0-MTP-Q8_0.gguf}"
|
||||
: "${ALIAS:=frank-zealot}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
@@ -119,7 +119,7 @@ fi
|
||||
# above are what pay for it. The draft cache dtype IS a further knob
|
||||
# (--spec-draft-type-k / --spec-draft-type-v, both default f16 — verified in this
|
||||
# build's --help) and stays at its default here. Card gate: quantized V alongside
|
||||
# MTP needs fork build >= prism-b10776, which is why LLAMA_BIN is the 10777
|
||||
# MTP needs fork build >= prism-b10776; LLAMA_BIN is 12118 (b10777 kept as rollback)
|
||||
# prebuilt; the type-142 loader lock keeps it pinned regardless.
|
||||
# SPEC_TYPE=none is the rollback and strips the whole set (flags move together).
|
||||
: "${SPEC_TYPE:=draft-mtp}"
|
||||
|
||||
@@ -94,6 +94,16 @@ for path in "${CHANGED[@]}"; do
|
||||
dankserver/.env.example|dankclaw/.env.example)
|
||||
add_test tests/env_consistency.bats
|
||||
;;
|
||||
# The SSD-backed metadata view: the installer, the host mount unit, and the
|
||||
# two media units that bind the view rather than the raw array.
|
||||
scripts/media-view.sh|dankserver/host/*)
|
||||
add_test tests/media_overlay.bats
|
||||
add_test tests/dankserver_quadlet.bats
|
||||
add_test tests/deployment_scoping.bats
|
||||
;;
|
||||
dankserver/jellyfin/quadlet/*|dankserver/plex/quadlet/*)
|
||||
add_test tests/media_overlay.bats
|
||||
;;
|
||||
shared/homepage/*)
|
||||
add_test tests/homepage_caddy.bats
|
||||
add_test tests/homepage_config.bats
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# wait-for-mount.sh — refuse a container start until what it binds is mounted.
|
||||
#
|
||||
# Why this exists (live 2026-10-31, Dankserver): a rootless quadlet cannot order
|
||||
# itself against a SYSTEM mount unit. `RequiresMountsFor=/mnt/entropy` in a user
|
||||
# unit resolves to nothing and is inert, so when the array's mount finished a
|
||||
# second after podman resolved the bind, Jellyfin and Plex started against the
|
||||
# pre-mount directory. Their library root looked EMPTY and Jellyfin began
|
||||
# deleting metadata by the thousand (`Removing item, Type: "Season"`). A blind
|
||||
# bind is not a cosmetic fault; it is a metadata-deleting machine.
|
||||
#
|
||||
# A `ConditionPathIsMountPoint=` is NOT the fix, measured on the same host: an
|
||||
# unmet condition logs "skipped, unmet condition check", NRestarts=0, and no
|
||||
# start attempt ever follows — the unit stays dead until a human starts it.
|
||||
# That is fail-dead. This script WAITS (the mount usually lands within seconds)
|
||||
# and only then refuses, so `Restart=always` carries the unit across the race.
|
||||
#
|
||||
# usage: wait-for-mount.sh [-t SECONDS] MOUNTPOINT [MOUNTPOINT...]
|
||||
# exit 0 every path is a mountpoint
|
||||
# exit 1 a path is still not a mountpoint after the window (named on stderr)
|
||||
# exit 2 usage error
|
||||
set -euo pipefail
|
||||
|
||||
usage() { printf 'usage: %s [-t SECONDS] MOUNTPOINT [MOUNTPOINT...]\n' "$0"; }
|
||||
|
||||
window=90
|
||||
while getopts ":t:h" opt; do
|
||||
case "$opt" in
|
||||
t) window=$OPTARG ;;
|
||||
h) usage; exit 0 ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
[ "$#" -ge 1 ] || { usage >&2; exit 2; }
|
||||
case "$window" in
|
||||
'' | *[!0-9]*) printf 'refused: -t wants whole seconds, got %s\n' "$window" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
is_mount() { findmnt -M "$1" -rno TARGET >/dev/null 2>&1; }
|
||||
|
||||
report() {
|
||||
local why=$1; shift
|
||||
printf '%s:' "$why"
|
||||
for p in "$@"; do printf ' %s' "$p"; done
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
deadline=$(( $(date +%s) + window ))
|
||||
warned=0
|
||||
while :; do
|
||||
missing=()
|
||||
for path in "$@"; do
|
||||
is_mount "$path" || missing+=("$path")
|
||||
done
|
||||
[ "${#missing[@]}" -eq 0 ] && break
|
||||
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
report "refused: still not mounted after ${window}s" "${missing[@]}" >&2
|
||||
printf 'refusing to start: a container bind-mounted here would see the empty\n' >&2
|
||||
printf 'directory underneath and conclude the library is empty — which is how a\n' >&2
|
||||
printf 'scan begins deleting metadata it cannot find.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$warned" -eq 0 ]; then
|
||||
report "waiting for mount(s) this unit depends on" "${missing[@]}"
|
||||
warned=1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
[ "$warned" -eq 0 ] || printf 'mounted; proceeding.\n'
|
||||
exit 0
|
||||
@@ -48,6 +48,14 @@ Use the `Authorization: token` header for everything.
|
||||
- **Read an issue**: `curl -s -K "$cfg" $API/issues/<number>` (fields: `number`, `title`, `body`, `labels[].name`, `state`)
|
||||
- **List issues**: `curl -s -K "$cfg" "$API/issues?state=open&limit=50"` — add `&labels=<id>` to filter
|
||||
- **Comment**: `curl -s -K "$cfg" -X POST -d "$(jq -n --rawfile b "$BODY" '{body:$b}')" $API/issues/<number>/comments`
|
||||
- **Comment on a PR**: the conversation thread is `POST $API/issues/<PR-number>/comments`
|
||||
(201, and the answer's `html_url` points at `pulls/<n>`; Gitea's issue index is
|
||||
unified, so the PR's number is the same number). `$API/pulls/<n>/comments` is the
|
||||
**review**-comment store and answers `405` to a plain body.
|
||||
- **A body built by `jq --rawfile b /dev/stdin` inside `$(…)` is empty**: the command
|
||||
substitution does not inherit the heredoc, so Gitea receives `{}` and answers 422 —
|
||||
and `jq -r '.id'` on the error prints nothing, which reads as success. Write the
|
||||
body to a file and pass `--rawfile` that file.
|
||||
- **Apply labels**: `POST $API/issues/<number>/labels` with `{"labels":[<label-ID numbers>]}` — Gitea wants numeric label **IDs**, not names; resolve them from `GET $API/labels` (see [[Triage Labels]] for which labels exist)
|
||||
- **Remove a label**: `DELETE $API/issues/<number>/labels/<label-id>`
|
||||
- **Close**: `PATCH $API/issues/<number>` with `{"state":"closed"}`
|
||||
|
||||
@@ -100,3 +100,12 @@ aborts with `DEPLOY INCOMPLETE: e2e gate FAILED` when it does not pass
|
||||
hand-off list (`HANDOFFS`) instead of a single host. The guard contract is
|
||||
untouched: challenge cleared, no authentik `uidField`, status < 500, and every
|
||||
host NOT in the list still has to land on its own vhost.
|
||||
7. **The runner's resolver is part of the suite.** A 2026-10-31 deploy of one
|
||||
Dankserver quadlet failed the gate with 21 reds spread over SSO, homepage RBAC
|
||||
and every vhost crawl — precisely the specs that navigate to an absolute
|
||||
`*.franksdank.cloud` name, while every spec on the relative `baseURL` stayed
|
||||
green. The error was `net::ERR_NAME_NOT_RESOLVED`: the workstation had lost its
|
||||
wired link, and on the fallback its resolver was no longer pinned to Pi-hole.
|
||||
Nothing on the fleet was wrong — the same suite re-run minutes later passed
|
||||
37/37. Before a mass red gets blamed on the guard chain, run
|
||||
`resolvectl query grafana.franksdank.cloud` on the box that runs the gate.
|
||||
@@ -167,6 +167,43 @@ Gate: `tests/quadlet_image_pins.bats`. Where upstream publishes no version
|
||||
tags (copyparty-ac stops at 1.19.15), pin `:latest@sha256:` and name the
|
||||
version the config label carries in the comment beside it.
|
||||
|
||||
## 4b. Resource control needs host permission (measured 2026-10-31, Dankserver)
|
||||
|
||||
`IOWeight=` in a rootless quadlet does nothing on its own, and nothing says so.
|
||||
The system manager delegates `cpu memory pids` to `user@.service` and **not** `io`;
|
||||
systemd drops a resource line it cannot honour **without a word** — the generator
|
||||
still emits `IOWeight=10`, the container still starts and answers its health check,
|
||||
and the cgroup simply has no `io.weight` file. Same family as
|
||||
`RequiresMountsFor=` (inert in the user manager) and `ConditionPathIsMountPoint=`
|
||||
(fail-dead): the line is accepted, the unit is happy, the setting is dead.
|
||||
|
||||
Two system drop-ins make it real, both placed by `ensure_user_io_delegate` in
|
||||
`scripts/deploy-remote.sh` (repo copies under `dankserver/host/systemd/`):
|
||||
|
||||
| File | Effect |
|
||||
|---|---|
|
||||
| `user.slice.d/io-accounting.conf` → `[Slice] IOAccounting=yes` | puts `io` into `user.slice/cgroup.subtree_control`; lands immediately, no restart |
|
||||
| `user@1000.service.d/io-delegate.conf` → `[Service] DelegateControllers=cpu memory pids io` | the user manager can only request controllers it was delegated; read when it starts, so it lands on **the next reboot** (restarting it kills every rootless container on the host) |
|
||||
|
||||
Read the chain in this order — each line is the failure mode of the one after it:
|
||||
|
||||
```bash
|
||||
cat /sys/fs/cgroup/user.slice/cgroup.subtree_control # wants: io
|
||||
systemctl show user@1000.service -p DelegateControllers # wants: io
|
||||
cat /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/\<unit\>.service/libpod-payload-*/io.weight
|
||||
```
|
||||
|
||||
`io` missing from the first → the accounting drop-in is absent. Missing from the
|
||||
second → the host has not rebooted since the delegation landed (the deployer prints
|
||||
`NOTE: io is not delegated …` for exactly this). No file at the third → that unit
|
||||
carries no weight.
|
||||
|
||||
Two limits that survive the fix: a container's weight covers **everything** in its
|
||||
cgroup, so a library scan and a playback stream share one weight; and the comparison
|
||||
is made *inside* `user.slice`, so a rootless unit's weight cannot push it below a
|
||||
root unit (`frank-archon`). Shaping that pairing is the weight of `user.slice`
|
||||
itself — a fleet decision, not a unit line.
|
||||
|
||||
## 5. Rootless Quadlet log retrieval
|
||||
|
||||
Step 0 — establish ownership: `systemctl --user status <name>.service`
|
||||
@@ -195,8 +232,9 @@ deploy.sh, never a live env poke.
|
||||
check every namespace (`sudo podman ps -a`) and native units
|
||||
(`systemctl list-units`) before declaring a service absent. Compare
|
||||
compose mount sources against the real filesystem — compose files name
|
||||
phantom paths (e.g. `/mnt/media/*` vs the real LUKS array at
|
||||
`/mnt/entropy`). Fix compose through git first, deploy second.
|
||||
phantom paths (the legacy `dankserver/compose.yaml` still names `/opt/jellyfin/*`
|
||||
for a service whose roots moved to `/home/dank/Containers/jellyfin`). Fix
|
||||
compose through git first, deploy second.
|
||||
2. **Pre-create bind-mount sources** (`sudo mkdir -p` + `chown 1000:1000`)
|
||||
or creation fails `statfs …: no such file or directory`.
|
||||
3. **Staged bring-up:** infra first (`up -d mariadb redis`), poll
|
||||
|
||||
@@ -1,51 +1,68 @@
|
||||
---
|
||||
type: reference
|
||||
description: "The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at"
|
||||
description: "Dankserver's /mnt/entropy volume: the LUKS/array chain, the crypttab + fstab boot path, and the containers bound to it"
|
||||
tags:
|
||||
- dankserver
|
||||
timestamp: 2026-08-28
|
||||
timestamp: 2026-10-31
|
||||
---
|
||||
|
||||
# Entropy Volume
|
||||
|
||||
The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at
|
||||
`/mnt/entropy`. The command-line helper reuses the host's desktop-integrated
|
||||
`udisks2`, `crypttab`, and `fstab` configuration; it does not store or handle
|
||||
the passphrase itself.
|
||||
`/mnt/entropy` is the ext4 filesystem on the LUKS container holding `/dev/md0`
|
||||
(RAID6, seven disks). The media stack's data lives there, so the mount is a boot
|
||||
dependency for those containers rather than a convenience.
|
||||
|
||||
## Command-line mount
|
||||
|
||||
```bash
|
||||
/home/dank/serverAppConfigs/scripts/mount-entropy.sh
|
||||
```
|
||||
|
||||
Run it as the normal desktop user (`dank`), not from a root shell. `udisksctl`
|
||||
will securely prompt for the LUKS passphrase. `sudo mount /mnt/entropy` then
|
||||
uses the existing `/etc/fstab` entry, preserving its options:
|
||||
## Chain
|
||||
|
||||
```text
|
||||
nosuid,nodev
|
||||
/dev/md0 <- mdadm (its mdadm.conf ARRAY is what makes md0 exist at boot)
|
||||
-> LUKS UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec keyfile: /etc/entropy
|
||||
-> /dev/mapper/entropy <- systemd-cryptsetup@entropy.service
|
||||
-> ext4 at /mnt/entropy <- mnt-entropy.mount, from /etc/fstab
|
||||
```
|
||||
|
||||
The existing mountpoint is owned by `dank:dankserver` with mode `0755`; the
|
||||
helper deliberately does not run `chmod` or `chown`, so it cannot silently
|
||||
change those permissions.
|
||||
## Files that own it
|
||||
|
||||
## Device path
|
||||
| Repo file | Installs to | Produces at boot |
|
||||
|---|---|---|
|
||||
| `dankserver/host/crypttab` | `/etc/crypttab` | `systemd-cryptsetup@entropy.service` → `/dev/mapper/entropy` |
|
||||
| `dankserver/host/fstab` | `/etc/fstab` | `mnt-entropy.mount` (mountpoint `dank:dankserver`, `0755`) |
|
||||
|
||||
The current storage chain is:
|
||||
Deploy a change by copying the file onto the host, then
|
||||
`systemctl daemon-reload && update-initramfs -u`. The initramfs step is not
|
||||
optional: the cryptsetup hook bakes `/etc/crypttab` into the initrd, and the
|
||||
array is unlocked there, before the real root mounts. `scripts/mount-entropy.sh`
|
||||
(`udisksctl unlock` + `mount`) stays as the manual path for a volume that was
|
||||
never unlocked; on a correct boot it is redundant.
|
||||
|
||||
```text
|
||||
/dev/md0 -> LUKS UUID cd86c728-459e-4f3a-bcc2-41ecf84685ec
|
||||
-> ext4 label entropy
|
||||
-> /mnt/entropy
|
||||
```
|
||||
## Law
|
||||
|
||||
The helper checks that `/dev/md0` exists and exits successfully if the volume
|
||||
is already mounted. It is intentionally specific to this host and volume:
|
||||
that avoids accidentally unlocking or mounting a different disk.
|
||||
**`crypttab`'s device field takes the LUKS UUID, never the md array UUID.**
|
||||
`blkid` prints both on adjacent lines for a RAID-backed container, and the file
|
||||
shipped since 2025 carried the array UUID (`61bd6eaf-…`) where the LUKS container
|
||||
is `cd86c728-…`. The generator produced a perfectly valid unit pointing at a
|
||||
device that never appears, so the mapper came into existence only through a
|
||||
desktop `udisks2` session — the volume looked mounted on every login while every
|
||||
boot failed to mount it. The matching `fstab` line named the same wrong UUID
|
||||
directly instead of the mapper.
|
||||
|
||||
## Related
|
||||
**A LUKS keyfile is hashed whole, so a trailing newline is a different
|
||||
passphrase.** The shipped `/etc/entropy` was 27 bytes — 26 characters plus `\n` —
|
||||
and `cryptsetup` rejected it even after the UUID was corrected. Keyfiles stay
|
||||
`0600` with no trailing newline; prove it with
|
||||
`cryptsetup open --test-passphrase --key-file /etc/entropy UUID=…` *before*
|
||||
relying on it for a boot.
|
||||
|
||||
- [[Storage Policy]]
|
||||
- [[Dankserver Stack]]
|
||||
**Prove a mount fix with the artifacts systemd generates, not with a live
|
||||
mount.** The regenerated `systemd-cryptsetup@entropy.service` naming the right
|
||||
device, `findmnt /mnt/entropy` sourcing `/dev/mapper/entropy`, and a matching
|
||||
checksum between the repo copy and the host copy are the evidence. A mount that
|
||||
exists because someone logged in proves nothing about the boot path — that
|
||||
exactly describes the year this bug survived.
|
||||
|
||||
## Consumers
|
||||
|
||||
`jellyfin`, `copyparty`, and `plex` bind paths under `/mnt/entropy`; with the
|
||||
volume absent they start `active (healthy)` against empty directories, which is
|
||||
how a missing 91 TiB volume hides. See [[Dankserver Stack]] for the stack and
|
||||
[[Storage Policy]] for what may be written here.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
type: reference
|
||||
title: Jellyfin
|
||||
description: "Jellyfin on Dankserver: quadlet unit, entropy-volume data layout, ports, client access, hardware-transcode status."
|
||||
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status."
|
||||
tags: [jellyfin, dankserver, media-server]
|
||||
timestamp: 2026-09-03T00:00:00Z
|
||||
timestamp: 2026-10-30T00:00:00Z
|
||||
---
|
||||
|
||||
# Jellyfin
|
||||
@@ -18,7 +18,7 @@ clients.
|
||||
public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
|
||||
[[Authentik]] outpost (post-auth route proxies `localhost:8096`).
|
||||
|
||||
## Facts (verified from the live unit, 2026-09-03)
|
||||
## Facts (the repo quadlet; the live unit is still the pre-view generation — see Data layout)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
@@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
|
||||
| Image | `ghcr.io/jellyfin/jellyfin:latest` |
|
||||
| Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp |
|
||||
| Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` |
|
||||
| Media | `Volume=/mnt/entropy:/data:ro` — the whole [[Entropy Volume]] mounted read-only; nothing automated writes to it |
|
||||
| Media | `Volume=/var/lib/media-meta/view:/data` in git — the [[Media Metadata View]] overlay, **writable**, sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched. **The live unit is still pre-view: `/mnt/entropy:/data:ro`** — nothing is written into the library until the view is deployed |
|
||||
| GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) |
|
||||
| Env quirks | `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`, `LD_PRELOAD=libjemalloc.so.2`, `MALLOC_TRIM_THRESHOLD_=131072` kept verbatim from the compose-era container (zero-drift cutover) |
|
||||
| Health | `curl -f http://localhost:8096/health` every 30 s |
|
||||
@@ -36,11 +36,27 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
|
||||
```
|
||||
/home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR)
|
||||
/home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME)
|
||||
/mnt/entropy -> /data (read-only media volume)
|
||||
/var/lib/media-meta/view -> /data (git: the overlay view)
|
||||
/mnt/entropy -> /data (live today: the array, read-only)
|
||||
```
|
||||
|
||||
Library folders point at `/data` paths; because the mount uses the same
|
||||
absolute path as on the host, library paths survive container recreation.
|
||||
Library folders point at `/data` paths: stable across container recreation and
|
||||
identical to the raw array layout below. Which side of that path the container
|
||||
is on is the open question on this host — the installed unit binds the read-only
|
||||
array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31).
|
||||
|
||||
`Local metadata` — save metadata to media folders — is enabled, and it is the
|
||||
reason the view exists: `movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork
|
||||
written once on the NVMe upper layer are files [[Plex]] can read too. While
|
||||
`/data` is bound read-only every item update logs the error below and no sidecar
|
||||
is written; Jellyfin becomes the fleet's only metadata writer with the deploy
|
||||
that raises the view.
|
||||
|
||||
**Failure signature:** `System.IO.IOException: Read-only file system :
|
||||
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array
|
||||
instead of the view. On Dankserver that is today's expected state — the view has
|
||||
never been installed (`scripts/media-view.sh status`). Once it is deployed, the
|
||||
same line is a fault: the quadlet drifted or the mount died.
|
||||
|
||||
## Ports & access
|
||||
|
||||
@@ -105,12 +121,109 @@ where the LDAP plugin runs. Verified 2026-09-25: `alex` authenticates over
|
||||
`AuthenticateByName` (HTTP 200; full plugin flow logged — service bind,
|
||||
search, `ldapUid`, user bind, token).
|
||||
|
||||
## Metadata providers and their keys (2026-10-30)
|
||||
|
||||
TMDb, OMDb, StudioImages and MusicBrainz are **core plugins** in Jellyfin 12 —
|
||||
no DLL, just a configuration file the running container owns under the data
|
||||
root. TVDB is not core (its metadata provider was removed in 10.9), so it is an
|
||||
official-catalog plugin.
|
||||
|
||||
| Provider | Shape on disk | Key lives in |
|
||||
|---|---|---|
|
||||
| TMDb | `config/plugins/configurations/Jellyfin.Plugin.Tmdb.xml` → `<TmdbApiKey>` | `/home/dank/Containers/jellyfin/provider-keys` → `TMDB_API_KEY=` (mode 600) |
|
||||
| TVDB | `config/plugins/thetvdb_24.0.0.0/` + `…Jellyfin.Plugin.Tvdb.xml` → `<ApiKey>` | the same file → `TVDB_API_KEY=` |
|
||||
| OMDb | `…Jellyfin.Plugin.Omdb.xml` | **not configured** — its key is issued by email, 1 000 req/day, and its ToS forbids storing the returned data; ratings stay Jellyfin's own lookup |
|
||||
|
||||
**None of it is tracked in git.** Those XML files belong to the container and
|
||||
hold credentials, and `tests/jellyfin_providers.bats` fails the suite if one is
|
||||
ever committed. The keys themselves sit in the per-service host env file that is
|
||||
the fleet's convention for container credentials (grafana, caddy, pihole,
|
||||
copyparty, romm, seerr): `provider-keys`, mode 600, beside the data root.
|
||||
`scripts/jellyfin-providers.sh` is the only thing that writes the XML:
|
||||
|
||||
```
|
||||
scripts/jellyfin-providers.sh plan # what would change; prints no key
|
||||
scripts/jellyfin-providers.sh install # plugin + keys, previous copy to trash
|
||||
scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes
|
||||
```
|
||||
|
||||
- **A key never enters git, argv, or the tool's output.** The installer reads
|
||||
that 600-perm file itself (a value in argv is world-readable through
|
||||
`/proc/<pid>/cmdline`) and refuses it when it is group- or world-readable —
|
||||
an exposed key is an error, not a warning. A key that shows up in a transcript
|
||||
or `ps` output has leaked: rotate it with the provider, rewrite
|
||||
`provider-keys`, re-run `install`.
|
||||
- **The TVDB artifact is pinned three ways**: sha256
|
||||
`6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319`, the
|
||||
official plugin guid `a677c0da-fac5-4cde-941a-7134223f14c8`, and version
|
||||
`24.0.0.0` (`targetAbi 12.0.0.0`, which fits the deployed 12.4.0). A sha
|
||||
match alone only proves the bytes are the bytes fetched — the guid check is
|
||||
what says it is the official plugin. Rollback: move `thetvdb_24.0.0.0/` into
|
||||
the trash and revert the pin; a second `thetvdb_*` directory is a refusal,
|
||||
never a quiet second copy.
|
||||
- **The homelab TVDB key is a licensed key** (its JWT claims say
|
||||
`community_supported: false`), so it needs **no PIN** — the optional
|
||||
`TVDB_PIN=` line exists only for a subscriber-shaped key.
|
||||
- **TVDB is installed and keyed but is not a metadata agent**: TMDb still
|
||||
identifies everything. Enabling TVDB as a provider would re-fetch show
|
||||
metadata across the library, so it is a deliberate, separate change.
|
||||
- **The deployer places the keys before it restarts jellyfin**
|
||||
(`ensure_provider_keys` in `scripts/deploy-remote.sh`) and warns instead of
|
||||
failing when `provider-keys` is absent, so a host without it still deploys.
|
||||
- **Keys and plugins are read at start-up**: after a manual `install` the
|
||||
change is inert until `./scripts/deploy.sh dankserver --service jellyfin`.
|
||||
- TMDb's terms are the reason this stays narrow: attribution required, no
|
||||
caching beyond 6 months, no derivative works. Fine for a personal homelab,
|
||||
and the reason no bulk enrichment pass runs against it.
|
||||
|
||||
## Configuration workflow
|
||||
|
||||
1. Create libraries pointing at `/data` folders (movies, shows, audio).
|
||||
2. Set metadata/language preferences (`LANG=en_US.UTF-8` pinned in the unit).
|
||||
3. Configure clients to use `http://192.168.0.235:8096` or the guarded vhost.
|
||||
|
||||
## Triggering a scan
|
||||
|
||||
The scan key lives in `~/.config/secrets/jellyfin` (mode 600; the same value is
|
||||
`JELLYFIN_API_KEY` in the ignored `dankserver/.env`). It rides a mode-600 curl
|
||||
config, never a header argument, in the `MediaBrowser` form: 12.4 authenticates
|
||||
only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token`
|
||||
for every key, valid or not. Before believing a 401, prove the key against the
|
||||
server's own table — `select count(*) from ApiKeys where AccessToken = ?` on
|
||||
`jellyfin.db` opened `mode=ro&immutable=1`. The backslashes below matter:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
{ printf 'header = "Authorization: MediaBrowser Token=\\"%s\\""\n' \
|
||||
"$(cat ~/.config/secrets/jellyfin)"
|
||||
echo 'url = "http://127.0.0.1:8096/Library/Refresh"'
|
||||
echo 'request = "POST"'; } >/tmp/jf.hdr
|
||||
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n"
|
||||
shred -u /tmp/jf.hdr
|
||||
```
|
||||
|
||||
`204` is acceptance; anything else queued no scan, so re-issue the identical
|
||||
request instead of diagnosing. A `401` from a key that answers `200` on a
|
||||
hand-typed request is this block's quoting, not the server: bash `printf`
|
||||
collapses `\"` to `"`, curl ends the quoted value at that quote, and Jellyfin
|
||||
receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers
|
||||
204, `\"` answers 401 — both run on this host).
|
||||
|
||||
One accepted refresh is the whole procedure. A second one while a scan runs moves
|
||||
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
|
||||
later `204` is accepted and then discarded — the status code stops meaning
|
||||
anything. The fix is `systemctl --user restart jellyfin.service` (never
|
||||
`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after
|
||||
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
||||
response code.
|
||||
|
||||
**A scan that must see a new folder has to be recursive.** `POST /Library/Refresh`
|
||||
is the incremental pass, and inotify does not cross a rootless podman bind, so
|
||||
folders created on the host stay invisible to it. The move is
|
||||
`POST /Items/{id}/Refresh?Recursive=true` (204) per library, the id from
|
||||
`GET /Library/VirtualFolders` — its field is `ItemId`, not `Id` — and that same
|
||||
record's `RefreshStatus` is the progress read.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[Plex]] — co-resident media server on the same volume
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
type: reference
|
||||
title: Media Audiobook Relayout
|
||||
description: "Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals."
|
||||
tags: [dankserver, media-server, plex, media-relayout]
|
||||
timestamp: 2026-10-31T00:00:00Z
|
||||
---
|
||||
|
||||
# Media Audiobook Relayout
|
||||
|
||||
`/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read
|
||||
at all — its music scanner has no audiobook concept — while [[Plex]] indexes all
|
||||
of it as Artist → Album → Track: the album is the BOOK, that book's single artist
|
||||
is the NARRATOR, both matched out of the files' own tags. Today one book is split
|
||||
across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection
|
||||
pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py`
|
||||
re-files the tree so the property both servers' book scanners assume is true —
|
||||
|
||||
Audiobooks/<Narrator>/<Book> (<year>)/<the file keeps its own name>
|
||||
|
||||
The shared gates, the executor, the journal law and the rollback live in
|
||||
[[Media Relayout]]; this page is the audiobook arm only.
|
||||
|
||||
```bash
|
||||
# 1. The authority: Plex's chapter rows for this root (the DB opens read-only).
|
||||
python3 scripts/media-census.py audiobooks --root /mnt/entropy/Audiobooks \
|
||||
--out /tmp/ab_rows.tsv
|
||||
# 2. What is on disk (stat only).
|
||||
python3 scripts/media-census.py enumerate --root /mnt/entropy/Audiobooks \
|
||||
--out /tmp/ab_enum.tsv
|
||||
# 3. The plan.
|
||||
python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \
|
||||
--enum /tmp/ab_enum.tsv --rows /tmp/ab_rows.tsv --out /tmp/ab_plan.tsv \
|
||||
--exceptions /tmp/ab_exceptions.txt
|
||||
# 4. Read the exceptions file FIRST. Then the same executor, journal and rollback
|
||||
# as [[Media Relayout]] shows, on its own journal, and refresh the Plex section
|
||||
# as in step 5 of the movie Procedure — from inside the container, never over
|
||||
# the host port.
|
||||
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
|
||||
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
|
||||
```
|
||||
|
||||
The group is the identity Plex asserts in its own database — **(narrator, book,
|
||||
year)** — so one book arriving in four part folders becomes one folder, and a
|
||||
1965 and a 2020 recording that share a title stay two books.
|
||||
|
||||
Two laws are forced by what the rows actually hold:
|
||||
|
||||
+- **A chapter is never renumbered.** Most books contain chapters whose index Plex
|
||||
does not know, so a number written into a name would be invented — and it would
|
||||
buy nothing, because both servers order playback by the embedded track tag, not
|
||||
by the file name. A wrong number in a name is the number a human later trusts.
|
||||
Fix order in the tags: a Plex scan re-reads tags and never renames a file.
|
||||
+- **The shelf is the narrator, not the author.** Every book has exactly one artist
|
||||
row and it is the narrator; the author survives only inside some album titles
|
||||
(`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving it would
|
||||
mean parsing titles where a ripper wrote one and inventing it everywhere else.
|
||||
The author stays visible in the folder name Plex itself shows.
|
||||
|
||||
What it refuses, the music arm's list with one addition: a video container, audio
|
||||
no chapter row names, and a loose file in a folder holding two books all park under
|
||||
`_unsorted` with the path they have today — and so does a chapter whose row names
|
||||
**no book or no narrator**: a placeholder (`[Unknown Album]`, `[Unknown Artist]`)
|
||||
that unrelated books share, or a chapter number mistagged as the artist (`0.4`).
|
||||
`Various Artists` is not a placeholder — that is a multi-cast read.
|
||||
|
||||
The reviewed plan lives at `/mnt/entropy/media-relayout-audiobooks-plan.tsv`; it
|
||||
applies on its own journal, behind the music writer, and then refreshes the Plex
|
||||
section. Verify it with `--db-kind plex` — Jellyfin holds no rows here, so a
|
||||
Jellyfin pass proves nothing either way.
|
||||
|
||||
## See Also
|
||||
|
||||
[[Media Relayout]], [[Media Music Relayout]], [[Plex]]
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
type: reference
|
||||
title: Media Metadata View
|
||||
description: "The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback."
|
||||
tags: [dankserver, media-server, jellyfin, plex, storage]
|
||||
timestamp: 2026-10-30T00:00:00Z
|
||||
---
|
||||
|
||||
# Media Metadata View
|
||||
|
||||
`/var/lib/media-meta/view` is an OverlayFS merge of the media library: the RAID6
|
||||
array below, a directory on the NVMe above. It exists so [[Jellyfin]] can write
|
||||
Kodi-format sidecars (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) that
|
||||
[[Plex]] can read. Sidecar metadata is only useful sitting next to the media
|
||||
file, and the array is read-only and slow — so the media bytes stay there and the
|
||||
*writes* land on SSD. One tree to both servers, one copy of the media.
|
||||
|
||||
The mount point is not a volume and holds nothing while unmounted. It sits inside
|
||||
`/var/lib/media-meta` so that layers, kernel bookkeeping, the reap trash and the
|
||||
merged door are one directory — and so the mount table grows no new `/mnt` entry
|
||||
next to the array.
|
||||
|
||||
**Not deployed on Dankserver — verified 2026-10-31.** `scripts/media-view.sh
|
||||
status` answers `view: /var/lib/media-meta/view NOT MOUNTED`,
|
||||
`/var/lib/media-meta` does not exist, and the installed quadlets under
|
||||
`~/.config/containers/systemd/` still carry the pre-view binds — `/mnt/entropy:/data:ro`
|
||||
for [[Jellyfin]], `/mnt/entropy:/mnt/entropy:ro` for [[Plex]]. Everything below
|
||||
is the shape git describes, not the shape the servers run. The cutover is an
|
||||
ordinary deploy: `deploy.sh dankserver --service jellyfin|plex` runs
|
||||
`ensure_media_view` (`install` then `verify`) before it restarts the container,
|
||||
so it must not be fired through the middle of a library scan. Until that deploy
|
||||
lands there is no upper layer, so no relayout can leave ghost metadata and
|
||||
`reap` has nothing to do — `media-apply.py` prints that follow-up only when the
|
||||
view really is a mountpoint.
|
||||
|
||||
## Shape
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Lower layer | `/mnt/entropy` — the RAID6 array, source of truth, never written |
|
||||
| Upper layer | `/var/lib/media-meta/upper` — NVMe; every write lands here |
|
||||
| Work dir | `/var/lib/media-meta/work` — overlayfs internals, same filesystem |
|
||||
| Mount point | `/var/lib/media-meta/view` — the merged tree both servers bind |
|
||||
| Unit (repo) | `dankserver/host/systemd/media-meta-view.mount` — the source of truth |
|
||||
| Unit (host) | `var-lib-media\x2dmeta-view.mount` — systemd names a mount unit after its escaped path (`-` becomes `\x2d`); `media-view.sh install` writes the repo file under that name |
|
||||
| Installer | `scripts/media-view.sh` — `plan` / `install` / `verify` / `status` / `reap` |
|
||||
| Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it |
|
||||
|
||||
Both consumers gate their start on the view with
|
||||
`ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh` (it waits
|
||||
for the mount, then refuses), and `scripts/deploy.sh dankserver --service
|
||||
jellyfin|plex` calls `media-view.sh install` + `verify` before it restarts
|
||||
either container.
|
||||
|
||||
| Consumer | Bind | Mode | Why that path |
|
||||
|---|---|---|---|
|
||||
| [[Jellyfin]] | `/var/lib/media-meta/view:/data` | rw | It is the metadata writer; its sidecars become SSD bytes |
|
||||
| [[Plex]] | `/var/lib/media-meta/view:/mnt/entropy` | ro | Every path in the PMS database was recorded as `/mnt/entropy/...`; only the HOST side of the bind moved, so its database still resolves |
|
||||
|
||||
Putting the sidecars inside Plex's reach is not the same as making Plex use them:
|
||||
a library parses `.nfo` only with its *Local media information* agent enabled, and
|
||||
this change touches no library setting.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
scripts/media-view.sh plan # print the resolved shape, touch nothing
|
||||
sudo scripts/media-view.sh install # create dirs, install the unit, mount, enable
|
||||
scripts/media-view.sh verify # is the view really the overlay? (deploy gate)
|
||||
scripts/media-view.sh status # mount options + upper-layer size/file count
|
||||
scripts/media-view.sh reap # list ghost metadata (media moved, sidecars left)
|
||||
sudo scripts/media-view.sh reap --apply # move ghosts to the SSD trash, never delete
|
||||
```
|
||||
|
||||
`--upper-base DIR` and `--lower DIR` relocate the layers for `plan` and `reap`;
|
||||
the defaults are the host values above.
|
||||
|
||||
## Laws
|
||||
|
||||
- **The upper layer must sit on non-rotational storage.** `install` and `verify`
|
||||
refuse via `lsblk -dno ROTA` if the upper base lives on the array: an overlay
|
||||
whose upper is the spinners is the original problem with extra steps, and it
|
||||
silently turns every metadata write back into a seek.
|
||||
- **A container that binds the array must refuse to start until the mount
|
||||
answers.** Live 2026-10-31: the array's mount and the media containers raced at
|
||||
boot, podman resolved the bind against the pre-mount directory, and both
|
||||
servers came up blind — Jellyfin read an empty library and began
|
||||
`Removing item, Type: "Season"` by the thousand. Two dependencies people reach
|
||||
for do not work here, both measured on that host: `RequiresMountsFor=` in a
|
||||
**rootless** unit is inert (the user manager cannot order against the system's
|
||||
`mnt-entropy.mount`), and `ConditionPathIsMountPoint=` is fail-*dead* — an
|
||||
unmet condition logs `skipped, unmet condition check` with `NRestarts=0` and no
|
||||
start attempt ever follows, so the unit waits for a human. The working guard is
|
||||
`scripts/wait-for-mount.sh` as `ExecStartPre`: it waits out the race, refuses by
|
||||
name after its window, and `Restart=always` carries the unit from there.
|
||||
systemd does not create a mount point, so `install` creates `view` itself;
|
||||
without it the mount simply fails.
|
||||
- **Never delete through the view.** A delete writes an overlayfs whiteout
|
||||
(`.wh.<name>`) in the upper layer, which hides the array's copy without
|
||||
removing it — the file reappears the moment the view is unmounted, and the
|
||||
upper layer now carries a landmine. Deletions belong on the raw path
|
||||
(`/mnt/entropy`), which the relayout tool already treats as the only writer.
|
||||
`reap` refuses to remove whiteouts and only warns about them.
|
||||
- **A relayout must be followed by `reap`.** Renames happen on the raw path, so
|
||||
sidecars the view already wrote stay at the old coordinates: a ghost folder
|
||||
the servers browse and find nothing in. `reap` lists upper-layer directories
|
||||
with no counterpart below and moves them (with their subtree) into a dated
|
||||
`.AGENTTRASH/` under the SSD base. It is recursive and file-safe: a sidecar
|
||||
that exists only above is normal, so only *directories* are candidates.
|
||||
- **File-watcher events do not cross the overlay.** Plex's NFO agent and
|
||||
Jellyfin's real-time monitor see inotify events for writes that land *in the
|
||||
upper layer*, not for changes on the array underneath. After any relayout or
|
||||
externally-written metadata, trigger a library refresh — see
|
||||
[[Media Relayout]] for the refresh procedure.
|
||||
|
||||
## Failure signatures
|
||||
|
||||
| Symptom | Meaning |
|
||||
|---|---|
|
||||
| `jellyfin.service` / `plex.service` fail with `refused: still not mounted after 90s: …` | The view or the array is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. |
|
||||
| Servers answer, library looks empty, Jellyfin logs `Removing item, Type: "Season"` by the thousand | Blind bind — the container started before the mount and bound the pre-mount directory. `podman exec jellyfin ls /data` against `ls /mnt/entropy` is the whole diagnosis; restarting the container re-resolves the bind. |
|
||||
| Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. |
|
||||
| A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. |
|
||||
| `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. |
|
||||
| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commit that pointed the two quadlets at the view and re-deploy both
|
||||
services — they bind `/mnt/entropy` read-only again, which is the pre-view shape.
|
||||
The NVMe upper layer can stay: nothing reads it once the binds are back, and it
|
||||
holds no state either server needs. Unmounting alone
|
||||
(`systemctl stop 'var-lib-media\x2dmeta-view.mount'`) is safe while no container
|
||||
binds it, and the array is never modified by any of this.
|
||||
|
||||
## Links
|
||||
|
||||
- [[Jellyfin]] — the metadata writer; its data layout section.
|
||||
- [[Plex]] — the read-only consumer; in-place bind-mount history.
|
||||
- [[Media Relayout]] — the tool that moves media on the raw path and the reason `reap` exists.
|
||||
- [[Entropy Volume]] — the array underneath.
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user