Compare commits
270
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab17129e2 | ||
|
|
f46bf60939 | ||
|
|
b3eb9c6314 | ||
|
|
a3ddb4c65a | ||
|
|
b5173033b6 | ||
|
|
53e4a777b4 | ||
|
|
52d6da4f49 | ||
|
|
d138788411 | ||
|
|
6c169fc4ad | ||
|
|
e6a85176c4 | ||
|
|
9ff7ce6a12 | ||
|
|
f25be876fb | ||
|
|
bad78079d7 | ||
|
|
fee8121d47 | ||
|
|
1b5c11fb60 | ||
|
|
cae283047f | ||
|
|
51007ac604 | ||
|
|
f7c41966b0 | ||
|
|
25114ca622 | ||
|
|
a07f492b89 | ||
|
|
9961ed62c0 | ||
|
|
70be24e9d5 | ||
|
|
70aa40a24a | ||
|
|
8f2c3bb378 | ||
|
|
146c28da9f | ||
|
|
b82655d30a | ||
|
|
0161ab2aa8 | ||
|
|
085a5d570c | ||
|
|
9ca69d4e7e | ||
|
|
64b06b9426 | ||
|
|
e519f6b554 | ||
|
|
1e0247f29e | ||
|
|
71e7e94b94 | ||
|
|
24d793eb7d | ||
|
|
95375683d5 | ||
|
|
e7d7c6eea2 | ||
|
|
e1bf1d6e80 | ||
|
|
ef306397a5 | ||
|
|
3896dce364 | ||
|
|
3f82c4d5d8 | ||
|
|
5030f83c55 | ||
|
|
b875256500 | ||
|
|
51abc6550d | ||
|
|
64f489023e | ||
|
|
3511e04ae9 | ||
|
|
21b7ad15d9 | ||
|
|
4ad55c28a0 | ||
|
|
c9a8469d04 | ||
|
|
5794b07c32 | ||
|
|
1a47b831ee | ||
|
|
5be66df2bb | ||
|
|
d3aedd1837 | ||
|
|
66766f23ef | ||
|
|
5cf5416471 | ||
|
|
30186d8365 | ||
|
|
3a6f7c7280 | ||
|
|
c7643fb4d0 | ||
|
|
882a9f6808 | ||
|
|
aa57c1a894 | ||
|
|
2a48751283 | ||
|
|
6083903ccb | ||
|
|
ee9eb353fb | ||
|
|
df597bb1b1 | ||
|
|
23ad0ca317 | ||
|
|
71d5804eaf | ||
|
|
5ef141eb17 | ||
|
|
fadbe10420 | ||
|
|
17ea2f4110 | ||
|
|
603513a15e | ||
|
|
5e0592381a | ||
|
|
ef89f77ea7 | ||
|
|
9e2584ec8b | ||
|
|
a5dfc1ee99 | ||
|
|
7e72126a07 | ||
|
|
80a65b013f | ||
|
|
54ec72da17 | ||
|
|
d82b8efdd7 | ||
|
|
27b3c24065 | ||
|
|
7e9bfbc2be | ||
|
|
f5410bd253 | ||
|
|
98522cecba | ||
|
|
3138e782bd | ||
|
|
393b77dfd2 | ||
|
|
cfdd3435bc | ||
|
|
f3a6b4f715 | ||
|
|
5c888ff948 | ||
|
|
03e0d84380 | ||
|
|
a5603d8f01 | ||
|
|
988c4899d4 | ||
|
|
77e9973ff5 | ||
|
|
625263dd48 | ||
|
|
42bc482ed8 | ||
|
|
aea24353ad | ||
|
|
d1493de31a | ||
|
|
93725f42b3 | ||
|
|
f80148c5db | ||
|
|
541067e6b2 | ||
|
|
8f0b1982c2 | ||
|
|
a405c5ed35 | ||
|
|
b232033b11 | ||
|
|
4a33898a6c | ||
|
|
21c98bd485 | ||
|
|
a651c81dfb | ||
|
|
0951d909c9 | ||
|
|
69effb542d | ||
|
|
e9314ea1ee | ||
|
|
ad9563412d | ||
|
|
f71c7ccce4 | ||
|
|
f541566714 | ||
|
|
006bbcef28 | ||
|
|
8889a4ceff | ||
|
|
56dfc6d0b9 | ||
|
|
f1ae1ddb36 | ||
|
|
c21c48e61e | ||
|
|
22cd17d064 | ||
|
|
48ef6d34cd | ||
|
|
317f36b456 | ||
|
|
7a8c6bd2e3 | ||
|
|
3c035ccef3 | ||
|
|
a6ec644ecb | ||
|
|
b7f4b7e148 | ||
|
|
ed1a20a960 | ||
|
|
511d8dca93 | ||
|
|
29aefc3b6f | ||
|
|
2e69215920 | ||
|
|
b93e8481f9 | ||
|
|
51ed5c0b2a | ||
|
|
9f9043c037 | ||
|
|
a25f139281 | ||
|
|
52600b28ab | ||
|
|
7cf388367e | ||
|
|
13830865f6 | ||
|
|
010b9affc7 | ||
|
|
ed5eed6d54 | ||
|
|
14f340370c | ||
|
|
50e0c5a786 | ||
|
|
649bbafa17 | ||
|
|
b7c19592e5 | ||
|
|
6c115c1394 | ||
|
|
6d79709cc0 | ||
|
|
0336f42e2a | ||
|
|
0f4a527337 | ||
|
|
1d92627935 | ||
|
|
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 | ||
|
|
f6298e3501 | ||
|
|
f14efdce1a | ||
|
|
a6fb0209e8 | ||
|
|
92a412e99d | ||
|
|
c238e32d6f | ||
|
|
da5eb968f9 | ||
|
|
b3052e9243 | ||
|
|
3e5a37a21f | ||
|
|
1e4fa14772 | ||
|
|
e6fba5d97e | ||
|
|
ca1475543d | ||
|
|
f83c241668 | ||
|
|
b4d7f6e7fe |
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 |
|
| 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 |
|
| 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-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 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-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:
|
Payload identity, shape, eras, laws and rollback live in ONE page each:
|
||||||
[[frank-archon]], [[frank-dragoon]], [[frank-zealot]]. `scripts/<service>.env`
|
[[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
|
(`<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
|
the quadlet test seam. The host-side unit is generated state; the repo file
|
||||||
is the source of truth.
|
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>` →
|
Before declaring a service deployed: `systemctl --user is-enabled <name>` →
|
||||||
`enabled`, `is-active` → `active`, and the container answers its health check.
|
`enabled`, `is-active` → `active`, and the container answers its health check.
|
||||||
@@ -218,9 +227,28 @@ See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
|
|||||||
|
|
||||||
## External Exposure Policy (MANDATORY)
|
## External Exposure Policy (MANDATORY)
|
||||||
|
|
||||||
Every Caddy-proxied service is either behind the auth chain (Anubis :8923 →
|
Every Caddy-proxied service is in exactly one of three postures: behind the
|
||||||
Authentik embedded outpost) or explicitly internal-only — no public DNS name,
|
auth chain (Anubis :8923 → Authentik embedded outpost), **exempt by
|
||||||
no vhost. New services default to internal-only until guarded.
|
construction** because it authenticates itself (`auth.`, git smart-HTTP, Gitea,
|
||||||
|
OpenWebUI, Jellyfin, Plex, Audiobookshelf, the *arr stack), or internal-only —
|
||||||
|
served to nobody off-LAN. New services default to internal-only until guarded.
|
||||||
|
|
||||||
|
**Internal-only is a source gate, not a missing DNS record.** Caddy answers
|
||||||
|
every Host it knows, and its per-name certificates are published in Certificate
|
||||||
|
Transparency logs, so a name with no Porkbun A record is still in a stranger's
|
||||||
|
reach (`crt.sh` lists it). A household-only service therefore carries an edge
|
||||||
|
gate — `@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8
|
||||||
|
172.16.0.0/12`, `handle @lan { reverse_proxy … }`, `handle { respond 404 }` —
|
||||||
|
which is what the *arr stack and qBittorrent carry (2026-11-04: Frank's law,
|
||||||
|
"never leaving the home boxes"). A future household-only vhost gets the same
|
||||||
|
block, not merely an absent record.
|
||||||
|
|
||||||
|
The guard chain is a **browser-only device**: Anubis demands a JavaScript
|
||||||
|
proof-of-work and the outpost works from a session cookie, so any non-browser
|
||||||
|
client — a native app, a podcast RSS reader, an OPDS client, `git`, a
|
||||||
|
service-to-service call — is answered with challenge HTML where it expected
|
||||||
|
JSON or a socket payload. A service that must serve such a client cannot sit
|
||||||
|
behind the chain; it has to own its authentication.
|
||||||
|
|
||||||
When guarding a new vhost — provider first, then route:
|
When guarding a new vhost — provider first, then route:
|
||||||
|
|
||||||
@@ -239,6 +267,10 @@ When guarding a new vhost — provider first, then route:
|
|||||||
Exempt categories (document the reason in the vhost comment): the IdP itself
|
Exempt categories (document the reason in the vhost comment): the IdP itself
|
||||||
(`auth.`), git smart-HTTP endpoints, services with their own complete auth.
|
(`auth.`), git smart-HTTP endpoints, services with their own complete auth.
|
||||||
|
|
||||||
|
Unguarding a vhost is the mirror image of the procedure above — the route flips
|
||||||
|
first and the IdP rows go last — and `serverAppConfigs-docs/Infrastructure/Caddy.md`
|
||||||
|
*Unguard a vhost* owns the steps.
|
||||||
|
|
||||||
## Deletion & Trash Policy (MANDATORY)
|
## Deletion & Trash Policy (MANDATORY)
|
||||||
|
|
||||||
Never `rm`. Move deletions into trash so they stay recoverable:
|
Never `rm`. Move deletions into trash so they stay recoverable:
|
||||||
@@ -256,7 +288,10 @@ archive.
|
|||||||
**Swap the model. Do not instrument it.** A payload change is: edit the trio
|
**Swap the model. Do not instrument it.** A payload change is: edit the trio
|
||||||
(env / serve script / unit) → `scripts/deploy.sh <host> --service <svc>` →
|
(env / serve script / unit) → `scripts/deploy.sh <host> --service <svc>` →
|
||||||
`/health` answers → one chat turn, one tool call, and one vision probe on an
|
`/health` answers → one chat turn, one tool call, and one vision probe on an
|
||||||
endpoint that serves images. That is the entire procedure.
|
endpoint that serves images → re-key that endpoint's OpenWebUI `model` row
|
||||||
|
(`meta.description` + `params.context.length`, API door in [[OpenWebUI]]). That is
|
||||||
|
the entire procedure — no deployer step touches `webui.db`, and a stale
|
||||||
|
`context.length` makes OpenWebUI advertise a window the endpoint does not grant.
|
||||||
|
|
||||||
- **No benchmarking unless Frank orders it.** No pre-swap baseline battery,
|
- **No benchmarking unless Frank orders it.** No pre-swap baseline battery,
|
||||||
no A/B arms, no decode/prefill ladders, no acceptance tables, no numbers
|
no A/B arms, no decode/prefill ladders, no acceptance tables, no numbers
|
||||||
@@ -293,8 +328,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
|
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
|
process owns the card. Use the runtime's own harness (`llama-bench`; a
|
||||||
live llama.cpp server also reports honest `timings` per request, including
|
live llama.cpp server also reports honest `timings` per request, including
|
||||||
`draft_n`/`draft_n_accepted` — but on frank-zealot's PrismML fork build
|
`draft_n`/`draft_n_accepted` — but on frank-zealot's PrismML fork that object
|
||||||
10777 that object only reaches you on **`/v1/chat/completions`**: a greedy
|
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
|
`ignore_eos` run on the raw `/completion` path there answers **500 "The
|
||||||
model produced output that does not match the expected Content-only
|
model produced output that does not match the expected Content-only
|
||||||
format"** (measured 2026-10-18). halogen has no `/completion` path at all,
|
format"** (measured 2026-10-18). halogen has no `/completion` path at all,
|
||||||
@@ -439,16 +474,16 @@ disagrees with it is the bug.
|
|||||||
### frank-zealot — llama.cpp (PrismML fork), RTX 3090, :7771
|
### frank-zealot — llama.cpp (PrismML fork), RTX 3090, :7771
|
||||||
|
|
||||||
- Owns: `scripts/frank-zealot.env`, `scripts/serve-frank-zealot.sh`,
|
- Owns: `scripts/frank-zealot.env`, `scripts/serve-frank-zealot.sh`,
|
||||||
`scripts/frank-zealot.service`; fork build `llama-prism-b10777` is the ONLY
|
`scripts/frank-zealot.service`; fork build `llama-prism-0244744` (build 12118)
|
||||||
loader (the payload carries the fork's custom PQ2_0 tensor type plus
|
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
|
`prism.hadamard.*` dequant metadata — stock llama.cpp refuses it, and a
|
||||||
loader ignoring that metadata dequantizes wrong); payload
|
loader ignoring that metadata dequantizes wrong); payload
|
||||||
`Bonsai2-9B-MTP/…PQ2_0.gguf` + the publisher's **Q8_0** mmproj (the BF16
|
`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
|
tower can crash the server with concurrent image requests) + the rank-1
|
||||||
refusal-direction adapter applied at load by `--lora-scaled …:1`. The
|
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
|
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
|
— and the fork BUILD is also the gate that lets it load: anything older than
|
||||||
with `unsupported prism.hadamard.version: 2`.
|
b10777 refuses it with `unsupported prism.hadamard.version: 2`.
|
||||||
- Shape: `--ctx-size 602112 --parallel 3` = **three conversations at
|
- Shape: `--ctx-size 602112 --parallel 3` = **three conversations at
|
||||||
200,704 each**, `CACHE_TYPE_K=f16` + `CACHE_TYPE_V=q8_0` (asymmetric: 25,088
|
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,
|
B/token, 14,406 MiB of pool), batch 6144 / ubatch 1024,
|
||||||
@@ -467,8 +502,8 @@ disagrees with it is the bug.
|
|||||||
`scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template
|
`scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template
|
||||||
`scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned
|
`scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned
|
||||||
`stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name.
|
`stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name.
|
||||||
- Shape: TP=2 over `wht6`, `--max-num-seqs 2`, `--max-model-len 200000`,
|
- Shape: TP=2 over `wht6`, `--max-num-seqs 4`, `--max-model-len 200000`,
|
||||||
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, `fp8` KV,
|
`expert_tiered` placement, 49,152 MiB pinned host pool per rank, `fp8` KV,
|
||||||
MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host
|
MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host
|
||||||
tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived
|
tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived
|
||||||
by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and
|
by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and
|
||||||
@@ -726,7 +761,12 @@ linked runbooks.
|
|||||||
**Ops**
|
**Ops**
|
||||||
|
|
||||||
- Never `pkill -f` a pattern that appears in your own command line — it kills your ssh session.
|
- Never `pkill -f` a pattern that appears in your own command line — it kills your ssh session.
|
||||||
Use `pkill -x <exact-name>`.
|
Use `pkill -x <exact-name>`, or the bracket trick — `pkill -f "serve[-]script.sh"` —
|
||||||
|
when the pattern has to be named: the bracket makes the pattern stop matching the
|
||||||
|
command line that carries it. Quote it: a zsh remote glob-expands an unquoted `[s]`
|
||||||
|
and the kill silently never runs. Re-hit 2026-11-02 —
|
||||||
|
`ssh host 'pkill -f jfshowswatch.py; …'` killed the remote shell mid-command, and
|
||||||
|
ssh exits **255 with zero output**, which reads exactly like a network fault.
|
||||||
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in committed scripts
|
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in committed scripts
|
||||||
(no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts without it.
|
(no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts without it.
|
||||||
- **ComfyUI on Dankclaw runs with GPU access** (`/dev/kfd` + `/dev/dri` via `AddDevice`,
|
- **ComfyUI on Dankclaw runs with GPU access** (`/dev/kfd` + `/dev/dri` via `AddDevice`,
|
||||||
@@ -741,6 +781,13 @@ linked runbooks.
|
|||||||
`/etc/default/grub`. It replaced `amd_iommu=off`, and the local A/B on byte-identical traffic
|
`/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.
|
says identity mode costs nothing, so the XDNA2 NPU returned for free.
|
||||||
[[dankclaw-iommu-translation-off]]
|
[[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
|
- **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
|
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.
|
rootless storage WITHOUT sudo — it is per-user storage, so `sudo podman logs` finds nothing.
|
||||||
@@ -800,6 +847,9 @@ per-attempt page and no per-swap ledger anywhere else.
|
|||||||
| `frank-tts` — cloned-voice speech, Dankclaw | `serverAppConfigs-docs/Dankclaw/Frank TTS.md` |
|
| `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` |
|
| 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` |
|
| 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` |
|
||||||
|
| Duplicate films and the purge hold (Dankserver) | `serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md` |
|
||||||
|
|
||||||
There is no page for a retired endpoint and no change diary. A payload swap
|
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
|
appends one row to the live endpoint's **Eras** table; when the endpoint itself
|
||||||
@@ -841,5 +891,7 @@ history.
|
|||||||
| Writing a doc | **One owner per fact** — edit the endpoint/service page; everywhere else links. Never restate a number. |
|
| 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. |
|
| 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`. |
|
| 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. **Deployed on Dankserver 2026-10-31, re-verified 2026-11-01: both quadlets bind the view, not the raw array. A restart for any reason discards that server's in-flight library scan, so re-trigger the refresh — see [[Media Metadata View]].** |
|
||||||
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
|
| 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. |
|
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
|
||||||
+83
-15
@@ -22,13 +22,16 @@
|
|||||||
# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth
|
# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth
|
||||||
# decision; Host headers survive every hop) → authenticated traffic is
|
# decision; Host headers survive every hop) → authenticated traffic is
|
||||||
# forwarded back to http://:8017 and routed straight to the app.
|
# forwarded back to http://:8017 and routed straight to the app.
|
||||||
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) and the
|
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, Plex, Audiobookshelf,
|
||||||
# Authentik IdP itself (auth.franksdank.cloud — its login flow must never sit
|
# *arr, Pi-hole) and the Authentik IdP itself (auth.franksdank.cloud — its login
|
||||||
# behind a challenge) are exempt by construction: their vhosts never route
|
# flow must never sit behind a challenge) are exempt by construction: their
|
||||||
# through Anubis. ComfyUI left this exempt set on 2026-08-31 and Grafana left
|
# vhosts never route through Anubis. The chain is browser-only by nature — a
|
||||||
# it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit
|
# native app client, a podcast RSS reader or a git client solves no JavaScript
|
||||||
# behind the guard chain; Grafana authenticates users itself via the OAuth2
|
# proof, so anything that must serve one has to own its own authentication.
|
||||||
# code flow, so no identity header is forwarded to it.
|
# ComfyUI left this exempt set on 2026-08-31 and Grafana left it on 2026-09-01
|
||||||
|
# (OAuth2 relying-party mode against authentik): both now sit behind the guard
|
||||||
|
# chain; Grafana authenticates users itself via the OAuth2 code flow, so no
|
||||||
|
# identity header is forwarded to it.
|
||||||
# The pihole vhost keeps its basicauth edge; the hash placeholder below is
|
# The pihole vhost keeps its basicauth edge; the hash placeholder below is
|
||||||
# rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into
|
# rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into
|
||||||
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
|
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
|
||||||
@@ -264,20 +267,67 @@ files.franksdank.cloud {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Jellyfin ---
|
# --- Audiobookshelf (exempt by construction: own auth) --------------------
|
||||||
|
# Caddy → app: no Anubis, no outpost. The guard chain answers a native client's
|
||||||
|
# /ping, /api/login and /socket.io/ handshake with challenge HTML, so behind it
|
||||||
|
# the phone app parsed a non-object where the socket `init` payload belongs and
|
||||||
|
# podcast RSS/OPDS feeds broke the same way. ABS authenticates itself (local
|
||||||
|
# accounts plus authentik OIDC), so it joins jelly/plex/*arr outside the chain
|
||||||
|
# (2026-11-03); the IdP rows that guarded this name — proxy provider
|
||||||
|
# `audiobooks-proxy` and application "Audiobookshelf Guard" — are retired with
|
||||||
|
# the change, after the route flips. Accepted exposure: /audiobookshelf/api/login
|
||||||
|
# is internet-visible with no rate limiter on either side (this Caddy build has
|
||||||
|
# no rate_limit module; ABS 2.37.2 ships no lockout) — the same class every other
|
||||||
|
# own-auth vhost here lives with, so shared accounts need long unique passwords.
|
||||||
|
# ROUTER_BASE_PATH=/audiobookshelf, so the bare root is a convenience hop onto it.
|
||||||
|
# X-Forwarded-Proto must say https: ABS derives its OIDC origin from
|
||||||
|
# x-forwarded-proto://Host and this hop is plain http, so without the pin its own
|
||||||
|
# https callback is a 400 "Invalid callback URL - must be same-origin".
|
||||||
|
audiobooks.franksdank.cloud {
|
||||||
|
redir / /audiobookshelf/ permanent
|
||||||
|
reverse_proxy 192.168.0.235:8000 {
|
||||||
|
header_up X-Forwarded-Proto https
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Jellyfin (exempt by construction: own auth) --------------------------
|
||||||
|
# No Anubis, no outpost: Jellyfin carries its own complete login (its own
|
||||||
|
# account store, no Authentik link), which is the policy's exempt category —
|
||||||
|
# the same family as plex/ABS/*arr around it. Accepted exposure: the sign-in
|
||||||
|
# form is internet-visible with no rate limiter on either side (this Caddy
|
||||||
|
# build ships no rate_limit module), so accounts need long unique passwords.
|
||||||
|
# The homepage card opened it to the household on 2026-11-03; accounts are
|
||||||
|
# created in Jellyfin's own admin UI, not by SSO.
|
||||||
jelly.franksdank.cloud {
|
jelly.franksdank.cloud {
|
||||||
reverse_proxy 192.168.0.235:8096
|
reverse_proxy 192.168.0.235:8096
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- *arr Stack (exempt by construction: own auth) ---
|
# --- *arr Stack + qBittorrent: LAN-ONLY at the edge (2026-11-04) -----------
|
||||||
|
# Frank's law: these never leave the home boxes. DNS secrecy is NOT a gate —
|
||||||
|
# Caddy issues one certificate per name and every one is in public CT logs
|
||||||
|
# (crt.sh lists prowlarr/radarr/lidarr/readarr/qbittorrent.franksdank.cloud),
|
||||||
|
# so a stranger can send the Host. The gate is the source address: a LAN client
|
||||||
|
# proxies through, anything off-LAN gets 404. `remote_ip` reads the real client
|
||||||
|
# address because the router DNATs the forward (no proxy hop in between).
|
||||||
prowlarr.franksdank.cloud {
|
prowlarr.franksdank.cloud {
|
||||||
reverse_proxy 192.168.0.112:9696
|
@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
|
||||||
|
handle @lan {
|
||||||
|
reverse_proxy 192.168.0.112:9696
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
radarr.franksdank.cloud {
|
radarr.franksdank.cloud {
|
||||||
reverse_proxy 192.168.0.112:7878
|
@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
|
||||||
|
handle @lan {
|
||||||
|
reverse_proxy 192.168.0.112:7878
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Sonarr: internal-only by construction (retired 2026-09-04) -----------
|
# --- Sonarr: internal-only by construction (retired 2026-09-04) -----------
|
||||||
@@ -288,15 +338,33 @@ radarr.franksdank.cloud {
|
|||||||
# zero-auth UI to the internet via manual DNS — retired.
|
# zero-auth UI to the internet via manual DNS — retired.
|
||||||
|
|
||||||
lidarr.franksdank.cloud {
|
lidarr.franksdank.cloud {
|
||||||
reverse_proxy 192.168.0.112:8686
|
@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
|
||||||
|
handle @lan {
|
||||||
|
reverse_proxy 192.168.0.112:8686
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readarr.franksdank.cloud {
|
readarr.franksdank.cloud {
|
||||||
reverse_proxy 192.168.0.112:8787
|
@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
|
||||||
|
handle @lan {
|
||||||
|
reverse_proxy 192.168.0.112:8787
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
qbittorrent.franksdank.cloud {
|
qbittorrent.franksdank.cloud {
|
||||||
reverse_proxy 192.168.0.112:8080
|
@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
|
||||||
|
handle @lan {
|
||||||
|
reverse_proxy 192.168.0.112:8080
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Internal listener: auth decision layer ---------------------------------
|
# --- Internal listener: auth decision layer ---------------------------------
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Network=dankclaw_default
|
|||||||
# Image=ghcr.io/goauthentik/ldap:2026.8.3 (amd64 digest; podman 5.7.0 refuses
|
# 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).
|
# the tag's nested manifest list, so pin the single-platform digest).
|
||||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
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_HOST=https://auth.franksdank.cloud
|
||||||
Environment=AUTHENTIK_INSECURE=false
|
Environment=AUTHENTIK_INSECURE=false
|
||||||
Environment=AUTHENTIK_TOKEN=${AUTHENTIK_LDAP_OUTPOST_TOKEN}
|
Environment=AUTHENTIK_TOKEN=${AUTHENTIK_LDAP_OUTPOST_TOKEN}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Wants=authentik-server.service
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=authentik-outpost
|
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
|
Exec=worker
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ After=network.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=authentik-postgres
|
ContainerName=authentik-postgres
|
||||||
Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
Image=docker.io/library/postgres:16-alpine@sha256:0cefaabd882e42bcb2a2f3dc56d3ce4770560ba62fd7f197f1a2e008fd5731ab
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||||
Environment=POSTGRES_DB=authentik
|
Environment=POSTGRES_DB=authentik
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Wants=authentik-postgres.service
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=authentik-server
|
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
|
Exec=server
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Wants=authentik-server.service
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=authentik-worker
|
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
|
Exec=worker
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ After=network.target
|
|||||||
[Container]
|
[Container]
|
||||||
ContainerName=gitea-postgres
|
ContainerName=gitea-postgres
|
||||||
Network=gitea.network
|
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
|
EnvironmentFile=/home/dank/Containers/gitea/env
|
||||||
Environment=POSTGRES_USER=gitea
|
Environment=POSTGRES_USER=gitea
|
||||||
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
|
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Description=Chrome DevTools Protocol daemon (headless-shell) for hermes
|
|||||||
[Container]
|
[Container]
|
||||||
ContainerName=chrome-devtools-protocol
|
ContainerName=chrome-devtools-protocol
|
||||||
Network=host
|
Network=host
|
||||||
Image=docker.io/chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04
|
Image=docker.io/chromedp/headless-shell@sha256:7bee201478b2f6266aae01243740ee85c16281e32f42d3aed9fdf04f4eace7a1
|
||||||
Entrypoint=/headless-shell/headless-shell
|
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
|
Exec=--no-sandbox --disable-gpu --use-gl=angle --use-angle=swiftshader --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
|
||||||
ShmSize=1073741824
|
ShmSize=1073741824
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ After=gluetun.service network.target
|
|||||||
[Container]
|
[Container]
|
||||||
ContainerName=marreta_browser
|
ContainerName=marreta_browser
|
||||||
Network=gluetun.container
|
Network=gluetun.container
|
||||||
Image=docker.io/lightpanda/browser:nightly@sha256:979c36ed869f1016b8dcf22939a0633c262f2c30e892a03a9f720eba2ff1931e
|
Image=docker.io/lightpanda/browser:nightly@sha256:e72730e364d1fbf352c1149f29ffa4b278f3b1a55ff0dec3262298ebe624ec7a
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
TimeoutStartSec=300
|
TimeoutStartSec=300
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=alertmanager
|
ContainerName=alertmanager
|
||||||
Image=docker.io/prom/alertmanager:latest@sha256:e9733bafb1bdef9b00e25a21f8f99dc26a22224bf16641ad754d1649f4c3357a
|
Image=docker.io/prom/alertmanager:latest@sha256:39d8bb3966c694a124428a3a88fbcffd40bd069129ecd47c059f8a3f17c55f3b
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
User=0:0
|
User=0:0
|
||||||
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
|
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Wants=network-online.target prometheus.service
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=grafana
|
ContainerName=grafana
|
||||||
Image=docker.io/grafana/grafana:latest@sha256:dbbc38ea21103cefb50396340cd88afec779c7e070b0a7bf5a75b4ce46651d3f
|
Image=docker.io/grafana/grafana:latest@sha256:488984b716c0862639f91a698adbe2862677a5a07bad42da5f03f3030883b205
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
User=0:0
|
User=0:0
|
||||||
PublishPort=127.0.0.1:3002:3000
|
PublishPort=127.0.0.1:3002:3000
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ After=network.target
|
|||||||
[Container]
|
[Container]
|
||||||
ContainerName=open-webui
|
ContainerName=open-webui
|
||||||
Network=open-webui.network
|
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
|
PublishPort=3005:8080
|
||||||
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
|
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
|
||||||
Environment=ANONYMIZED_TELEMETRY=False
|
Environment=ANONYMIZED_TELEMETRY=False
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
192.168.0.143 romm.franksdank.cloud
|
192.168.0.143 romm.franksdank.cloud
|
||||||
192.168.0.143 files.franksdank.cloud
|
192.168.0.143 files.franksdank.cloud
|
||||||
192.168.0.143 jelly.franksdank.cloud
|
192.168.0.143 jelly.franksdank.cloud
|
||||||
|
192.168.0.143 audiobooks.franksdank.cloud
|
||||||
192.168.0.143 radarr.franksdank.cloud
|
192.168.0.143 radarr.franksdank.cloud
|
||||||
192.168.0.143 lidarr.franksdank.cloud
|
192.168.0.143 lidarr.franksdank.cloud
|
||||||
192.168.0.143 readarr.franksdank.cloud
|
192.168.0.143 readarr.franksdank.cloud
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
|
|||||||
[Container]
|
[Container]
|
||||||
ContainerName=gluetun
|
ContainerName=gluetun
|
||||||
Network=searxng.network
|
Network=searxng.network
|
||||||
Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
|
Image=docker.io/qmcgaw/gluetun:latest@sha256:4af0c804003481e100180e1f8d4d5e9c48163fedc0f88d061881b615aef9d183
|
||||||
AddCapability=NET_ADMIN
|
AddCapability=NET_ADMIN
|
||||||
AddDevice=/dev/net/tun:/dev/net/tun
|
AddDevice=/dev/net/tun:/dev/net/tun
|
||||||
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
|
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
|
|||||||
ContainerName=searxng-valkey
|
ContainerName=searxng-valkey
|
||||||
Network=searxng.network
|
Network=searxng.network
|
||||||
NetworkAlias=valkey
|
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
|
Volume=/opt/searxng/valkey:/data
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ After=gluetun.service searxng-valkey.service network.target
|
|||||||
[Container]
|
[Container]
|
||||||
ContainerName=searxng
|
ContainerName=searxng
|
||||||
Network=gluetun.container
|
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_SECRET=${SEARXNG_SECRET}
|
||||||
Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL}
|
Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL}
|
||||||
Volume=/opt/searxng/config:/etc/searxng
|
Volume=/opt/searxng/config:/etc/searxng
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=seerr
|
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
|
Network=dankclaw_default
|
||||||
PublishPort=127.0.0.1:5055:5055
|
PublishPort=127.0.0.1:5055:5055
|
||||||
Volume=/home/dank/Containers/seerr/data:/app/config
|
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)
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Quadlet unit: Audiobookshelf audiobook server (rootless, boot-persistent).
|
||||||
|
#
|
||||||
|
# Install path on dankserver: ~/.config/containers/systemd/audiobookshelf.container
|
||||||
|
# Unit name: audiobookshelf.service (podman 5.x drops the container- prefix)
|
||||||
|
#
|
||||||
|
# Everything this server writes is its own state — the SQLite library, accounts,
|
||||||
|
# playback progress, cached covers — and it lives under
|
||||||
|
# /home/dank/Containers/audiobookshelf on the NVMe, so no metadata byte ever
|
||||||
|
# lands on the spindle. The audio itself is bound READ-ONLY off the array: the
|
||||||
|
# only sanctioned write path into /mnt/entropy is the overlay view
|
||||||
|
# (scripts/media-view.sh), Audiobookshelf has no need of it, and a read-only bind
|
||||||
|
# makes a stray sidecar write structurally impossible rather than discouraged.
|
||||||
|
#
|
||||||
|
# The digest is the linux/amd64 manifest of the tag, which is what podman
|
||||||
|
# verifies and what RepoDigests records on this single-arch host. Pinning the
|
||||||
|
# manifest-list digest instead would let a re-pull resolve to a different image.
|
||||||
|
# The upstream version tags stop at 2.19.0 (built 2025-02-02) while `latest` is
|
||||||
|
# newer by a year and a half, so `latest@digest` is the only pin that names the
|
||||||
|
# build actually running — same reasoning as the jellyfin and romm units.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Audiobookshelf audiobook server container
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=audiobookshelf
|
||||||
|
Image=ghcr.io/advplyr/audiobookshelf:latest@sha256:37c2057e150694970442579815c646fe6bdc01c95b9d1547c4c9068be8844ef3
|
||||||
|
Network=pasta
|
||||||
|
# The names are the image's own: the server reads PORT, CONFIG_PATH, METADATA_PATH
|
||||||
|
# and ROUTER_BASE_PATH (measured against the digest below — `podman inspect` shows
|
||||||
|
# PORT=80 CONFIG_PATH=/config METADATA_PATH=/metadata in the image ENV, and the
|
||||||
|
# server's source greps for exactly these). The ABS_* family printed by the server
|
||||||
|
# README is the npm package's naming and is INERT here: with ABS_PORT=8000 the
|
||||||
|
# server listened on :80 under base path /audiobookshelf, so the unit sat
|
||||||
|
# `active (running)` while every probe to :8000 was refused (measured 2026-11-02,
|
||||||
|
# first deploy).
|
||||||
|
# ROUTER_BASE_PATH MUST be /audiobookshelf: the SPA in /app/client/dist/index.html is
|
||||||
|
# built with `routerBasePath:"/audiobookshelf"` baked in, so it asks for its assets under
|
||||||
|
# that prefix no matter what the server is mounted at. With `/` the server answers the
|
||||||
|
# page but every script request falls through to the SPA fallback and comes back as
|
||||||
|
# text/html — Chrome refuses to execute it and the UI never leaves "Loading…"
|
||||||
|
# (measured 2026-11-03; the API and /healthcheck were fine, which is why an API-only
|
||||||
|
# verification missed it).
|
||||||
|
Environment=PORT=8000
|
||||||
|
Environment=ROUTER_BASE_PATH=/audiobookshelf
|
||||||
|
Environment=CONFIG_PATH=/config
|
||||||
|
Environment=METADATA_PATH=/metadata
|
||||||
|
Environment=TZ=America/Vancouver
|
||||||
|
PublishPort=8000:8000
|
||||||
|
Volume=/home/dank/Containers/audiobookshelf/config:/config
|
||||||
|
Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata
|
||||||
|
Volume=/mnt/entropy/Audiobooks:/audiobooks:ro
|
||||||
|
# The image is Alpine: it ships wget and no curl, so the health check must name
|
||||||
|
# the binary that exists or the container is unhealthy while serving perfectly.
|
||||||
|
# /healthcheck answers `OK` at the root AND under the base path (measured on both
|
||||||
|
# base paths), so the probe below is correct whatever ROUTER_BASE_PATH says.
|
||||||
|
HealthCmd=wget -q -O /dev/null http://127.0.0.1:8000/healthcheck
|
||||||
|
HealthInterval=30s
|
||||||
|
HealthTimeout=10s
|
||||||
|
HealthRetries=3
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Refuse to start until the array is a real mount — with the bind source missing,
|
||||||
|
# podman invents an empty directory and Audiobookshelf records an empty library as
|
||||||
|
# truth. See the header of scripts/wait-for-mount.sh for the blind-boot incident.
|
||||||
|
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /mnt/entropy
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
# The first boot scans the whole audiobook tree. Weighted LOW (default 100) so a
|
||||||
|
# scan loses to playback when the array is contended. Proportional share, not a
|
||||||
|
# cap: inert while the disk is idle.
|
||||||
|
IOWeight=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -43,7 +43,7 @@ After=network.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=calibre-server
|
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
|
Network=pasta
|
||||||
EnvironmentFile=/home/dank/Containers/calibre-server/env
|
EnvironmentFile=/home/dank/Containers/calibre-server/env
|
||||||
Environment=PUID=1000
|
Environment=PUID=1000
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ After=network.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=calibre-web
|
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
|
Network=pasta
|
||||||
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
|
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
|
||||||
Environment=LANG=en_US.UTF-8
|
Environment=LANG=en_US.UTF-8
|
||||||
|
|||||||
+6
-28
@@ -94,34 +94,12 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
# Jellyfin left this file when it became a Quadlet: the container is owned by
|
||||||
# Jellyfin — media server (movies, TV, music)
|
# dankserver/jellyfin/quadlet/jellyfin.container, its data roots are under
|
||||||
# ═══════════════════════════════════════════════
|
# /home/dank/Containers/jellyfin, and its library arrives through the SSD
|
||||||
jellyfin:
|
# overlay at /var/lib/media-meta/view (scripts/media-view.sh). The block that
|
||||||
image: jellyfin/jellyfin:latest
|
# was here named /opt/jellyfin/* and a read-only array bind — a second owner for
|
||||||
container_name: jellyfin
|
# the container name `jellyfin`, wrong on every path. One owner per container.
|
||||||
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
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
# Calibre-Web — e-book web frontend
|
# Calibre-Web — e-book web frontend
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ Volume=/home/dank/Containers/copyparty/cfg:/cfg
|
|||||||
PublishPort=3923:3923
|
PublishPort=3923:3923
|
||||||
|
|
||||||
[Service]
|
[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
|
EnvironmentFile=/home/dank/Containers/copyparty/env
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
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)
|
# Unit name: jellyfin.service (podman 5.x drops the container- prefix)
|
||||||
#
|
#
|
||||||
# Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at
|
# 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
|
# cutover. The library is seen through /var/lib/media-meta/view, the SSD-backed
|
||||||
# anywhere writes to the entropy volume. Every
|
# 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
|
# 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
|
# an Environment= line (zero drift); none of them are secrets, so this unit
|
||||||
# needs no EnvironmentFile.
|
# needs no EnvironmentFile.
|
||||||
@@ -16,7 +17,7 @@ After=network.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=jellyfin
|
ContainerName=jellyfin
|
||||||
Image=ghcr.io/jellyfin/jellyfin:latest@sha256:c3efa69a22f2691fc88d6bb035e87a8560e395a6047f062fb381c61c3185e8f5
|
Image=ghcr.io/jellyfin/jellyfin:latest@sha256:fea863d0cd9ec445985d9c75fc6ed64e38a1d2766cf0c250a33d553f4fc1b771
|
||||||
Network=dankserver_appnet
|
Network=dankserver_appnet
|
||||||
Environment=DEBIAN_FRONTEND=noninteractive
|
Environment=DEBIAN_FRONTEND=noninteractive
|
||||||
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
|
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
|
||||||
@@ -41,7 +42,17 @@ PublishPort=7359:7359/udp
|
|||||||
PublishPort=1900:1900/udp
|
PublishPort=1900:1900/udp
|
||||||
Volume=/home/dank/Containers/jellyfin/config:/config
|
Volume=/home/dank/Containers/jellyfin/config:/config
|
||||||
Volume=/home/dank/Containers/jellyfin/cache:/cache
|
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
|
||||||
|
# Big-screen theme for the TV. The repo file replaces the container's "Media
|
||||||
|
# Center" theme slot; the upstream dark palette stays live because the file
|
||||||
|
# @imports it, so an image bump cannot strand a vendored CSS snapshot. Read-only
|
||||||
|
# — the web app only fetches it. A client opts in with
|
||||||
|
# Display Settings -> Theme -> "Media Center".
|
||||||
|
Volume=/home/dank/serverAppConfigs/dankserver/jellyfin/theme/wmc/theme.css:/jellyfin/jellyfin-web/themes/wmc/theme.css:ro
|
||||||
AddDevice=/dev/dri
|
AddDevice=/dev/dri
|
||||||
GroupAdd=keep-groups
|
GroupAdd=keep-groups
|
||||||
HealthCmd=curl -f http://localhost:8096/health
|
HealthCmd=curl -f http://localhost:8096/health
|
||||||
@@ -50,8 +61,19 @@ HealthTimeout=10s
|
|||||||
HealthRetries=3
|
HealthRetries=3
|
||||||
|
|
||||||
[Service]
|
[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
|
Restart=always
|
||||||
RestartSec=5
|
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]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Dank fleet big-screen theme for jellyfin-web.
|
||||||
|
*
|
||||||
|
* Deployed by bind-mounting THIS file over the container's "Media Center"
|
||||||
|
* theme (`/jellyfin/jellyfin-web/themes/wmc/theme.css`) — see the Volume line
|
||||||
|
* in dankserver/jellyfin/quadlet/jellyfin.container. The slot is deliberately
|
||||||
|
* one nobody picks by accident, and its name is the only thing that changes:
|
||||||
|
* the colors are the live upstream dark theme, pulled in by @import below, so
|
||||||
|
* this file never vendors a snapshot of upstream CSS and cannot drift when the
|
||||||
|
* Jellyfin image moves.
|
||||||
|
*
|
||||||
|
* Why it exists: the living-room client (Jellyfin Desktop on danklivingroom)
|
||||||
|
* renders this server-served web app at 3840x2160 with no client-side scaling,
|
||||||
|
* so 14 px body text lands on a big panel viewed from three metres. `zoom` on
|
||||||
|
* the root element is the one lever that scales the whole UI — the layout code
|
||||||
|
* measures layout px, so card grids re-flow to fewer, larger columns instead of
|
||||||
|
* overflowing.
|
||||||
|
*
|
||||||
|
* Adoption is per device, not global: Display Settings -> Theme ->
|
||||||
|
* "Media Center" on the screen that wants it. That is the point — a phone
|
||||||
|
* browser on the same server must not get a 1.75x interface.
|
||||||
|
*
|
||||||
|
* Change the size by editing the single number. 1.5 is mild, 1.75 is the
|
||||||
|
* three-metre default, 2.0 is for a projector.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url("../dark/theme.css");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Marker the e2e gate reads back; also a tweak hook for a per-client
|
||||||
|
override injected by anyone who really wants one. */
|
||||||
|
--dank-big-screen: 1;
|
||||||
|
--dank-big-screen-zoom: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
zoom: var(--dank-big-screen-zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A remote moves focus in discrete jumps, so the ring has to survive a
|
||||||
|
three-metre read. */
|
||||||
|
.emby-focus,
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
.inputElement:focus-visible {
|
||||||
|
outline: 3px solid var(--accentColor, #00a4dc) !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars are mouse furniture; on a TV they are noise at this size. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
@@ -4,19 +4,33 @@ After=network-online.target
|
|||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Container]
|
[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
|
ContainerName=plex
|
||||||
Network=dankserver_appnet:alias=plex
|
Network=dankserver_appnet:alias=plex
|
||||||
Environment=PUID=0
|
Environment=PUID=0
|
||||||
Environment=PGID=0
|
Environment=PGID=0
|
||||||
Environment=TZ=America/Los_Angeles
|
Environment=TZ=America/Los_Angeles
|
||||||
Volume=/var/lib/plexmediaserver:/config
|
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
|
PublishPort=32400:32400
|
||||||
|
|
||||||
[Service]
|
[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
|
Restart=always
|
||||||
RestartSec=5
|
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]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
@@ -14,7 +14,7 @@ After=network.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=romm-mariadb
|
ContainerName=romm-mariadb
|
||||||
Image=docker.io/library/mariadb:11@sha256:f16d7a344413e519dff518f27bdb221388f84b0d274c97c4fb5f2cb0dc1b34fa
|
Image=docker.io/library/mariadb:11@sha256:c4124d1afa875e47f2892edf26b482fa54f796e2f342d3717a04ca091a22a9bd
|
||||||
Network=dankserver_appnet:alias=mariadb
|
Network=dankserver_appnet:alias=mariadb
|
||||||
EnvironmentFile=/home/dank/Containers/romm/env
|
EnvironmentFile=/home/dank/Containers/romm/env
|
||||||
Environment=MARIADB_DATABASE=romm
|
Environment=MARIADB_DATABASE=romm
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ After=network.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=romm-redis
|
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
|
Network=dankserver_appnet:alias=redis
|
||||||
Volume=/home/dank/Containers/romm/redis:/data
|
Volume=/home/dank/Containers/romm/redis:/data
|
||||||
HealthCmd=redis-cli ping
|
HealthCmd=redis-cli ping
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ After=romm-mariadb.service romm-redis.service network.target
|
|||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=romm
|
ContainerName=romm
|
||||||
Image=ghcr.io/rommapp/romm:latest@sha256:03295f95a6290e752641f9553ac4198b0e5b71b879d57f7b2371b98f608feb54
|
Image=ghcr.io/rommapp/romm:latest@sha256:8677fb7b81b1d3db20b849e989a1dadc2a11159e51eb34a2acdcb01102274edf
|
||||||
Network=dankserver_appnet
|
Network=dankserver_appnet
|
||||||
EnvironmentFile=/home/dank/Containers/romm/env
|
EnvironmentFile=/home/dank/Containers/romm/env
|
||||||
Environment=DB_HOST=mariadb
|
Environment=DB_HOST=mariadb
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ After=network-online.target
|
|||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Container]
|
[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
|
ContainerName=tautulli
|
||||||
Network=dankserver_appnet:alias=tautulli
|
Network=dankserver_appnet:alias=tautulli
|
||||||
Environment=PUID=0
|
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[@]}"
|
|
||||||
Executable
+204
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""abs-post-reorg.py — drive the Audiobookshelf side of the 2026-11-04 relayout.
|
||||||
|
|
||||||
|
Runs ON Dankserver (the ABS host). Credentials come from the machine-local
|
||||||
|
file ~/.config/secrets/audiobookshelf and never enter argv; requests go
|
||||||
|
through urllib so neither the password nor the bearer token is world-readable
|
||||||
|
in /proc. The delete arm is the DB-row arm ONLY — `hard=1` is fs.remove() of
|
||||||
|
the item folder and must never reach this file (see the Audiobookshelf vault
|
||||||
|
page; after the move the covering paths are whole books).
|
||||||
|
|
||||||
|
Subcommands
|
||||||
|
covers --list LIST.tsv copy each ABS /metadata cover onto the book
|
||||||
|
folder ("srchost<TAB>dstarray" per line);
|
||||||
|
run BEFORE the mover so the cover rides the
|
||||||
|
book's move, or AFTER with dst already final
|
||||||
|
wipe [--apply] delete every library item DB row (progress
|
||||||
|
included — the campaign accepted losing the
|
||||||
|
11 progress rows); refuses without --apply
|
||||||
|
scan [--wait] trigger the library scan; --wait polls
|
||||||
|
isFetchingLibrary then prints the item total
|
||||||
|
status item total + isMissing census
|
||||||
|
|
||||||
|
Paging law (measured twice): page on `page` (0-based) + `limit`; `offset` is
|
||||||
|
ignored outright. Count unique ids; stop on `total`.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8000/audiobookshelf"
|
||||||
|
SECRET = os.environ.get("ABS_SECRET", "/home/dank/.config/secrets/audiobookshelf")
|
||||||
|
|
||||||
|
|
||||||
|
def cred():
|
||||||
|
kv = {}
|
||||||
|
with open(SECRET) as fh:
|
||||||
|
for line in fh:
|
||||||
|
if "=" in line:
|
||||||
|
k, _, v = line.strip().partition("=")
|
||||||
|
kv[k] = v
|
||||||
|
return kv.get("ABS_ADMIN_USER", "frank"), kv.get("ABS_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, body=None, token=None, base=BASE):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(base + path, data=data, method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
raw = r.read().decode()
|
||||||
|
try:
|
||||||
|
return r.status, json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return r.status, raw.strip()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode()[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def login():
|
||||||
|
user, password = cred()
|
||||||
|
if not password:
|
||||||
|
sys.exit(f"refused: {SECRET} carries no ABS_ADMIN_PASSWORD")
|
||||||
|
code, body = call("POST", "/login", {"username": user, "password": password})
|
||||||
|
if code != 200:
|
||||||
|
sys.exit(f"login failed {code}: {body}")
|
||||||
|
return body["user"]["token"] # the token is inside user, not top level
|
||||||
|
|
||||||
|
|
||||||
|
def library_id(token, name="Audiobooks"):
|
||||||
|
code, libs = call("GET", "/api/libraries", token=token)
|
||||||
|
if code != 200:
|
||||||
|
sys.exit(f"libraries failed {code}: {libs}")
|
||||||
|
for lib in libs.get("libraries", []):
|
||||||
|
if lib["name"] == name:
|
||||||
|
return lib["id"]
|
||||||
|
sys.exit(f"no library named {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def all_item_ids(token, lid):
|
||||||
|
ids, page = [], 0
|
||||||
|
code, one = call("GET", f"/api/libraries/{lid}/items?limit=1", token=token)
|
||||||
|
total = one.get("total", -1) if code == 200 else -1
|
||||||
|
while True:
|
||||||
|
code, body = call("GET", f"/api/libraries/{lid}/items?page={page}&limit=500",
|
||||||
|
token=token)
|
||||||
|
if code != 200:
|
||||||
|
sys.exit(f"items page {page} failed {code}")
|
||||||
|
results = body.get("results", [])
|
||||||
|
if not results:
|
||||||
|
break
|
||||||
|
ids += [it["id"] for it in results]
|
||||||
|
page += 1
|
||||||
|
if total >= 0 and page * 500 >= total:
|
||||||
|
break
|
||||||
|
return list(dict.fromkeys(ids)), total
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_covers(args):
|
||||||
|
copied = missing = exists = 0
|
||||||
|
for line in open(args.list):
|
||||||
|
src, _, dst = line.rstrip("\n").partition("\t")
|
||||||
|
if not dst:
|
||||||
|
continue
|
||||||
|
if os.path.exists(dst):
|
||||||
|
exists += 1
|
||||||
|
continue
|
||||||
|
if not os.path.exists(src):
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
copied += 1
|
||||||
|
print(f"covers copied: {copied} already there: {exists} missing source: {missing}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_wipe(args, token, lid):
|
||||||
|
ids, total = all_item_ids(token, lid)
|
||||||
|
print(f"library {lid}: {len(ids)} items (total={total})")
|
||||||
|
if not args.apply:
|
||||||
|
print("dry run: nothing deleted (add --apply)")
|
||||||
|
return
|
||||||
|
gone = 0
|
||||||
|
for i, iid in enumerate(ids):
|
||||||
|
code, _ = call("DELETE", f"/api/items/{iid}", token=token)
|
||||||
|
if code == 200:
|
||||||
|
gone += 1
|
||||||
|
elif i < 3:
|
||||||
|
print(f"delete {iid}: {code}")
|
||||||
|
if i % 500 == 499:
|
||||||
|
print(f" {i + 1}/{len(ids)} deleted")
|
||||||
|
print(f"deleted {gone}/{len(ids)} item rows")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_scan(args, token, lid):
|
||||||
|
code, body = call("POST", f"/api/libraries/{lid}/scan", token=token)
|
||||||
|
print(f"scan: {code} {body if code != 200 else 'accepted'}")
|
||||||
|
if not args.wait:
|
||||||
|
return
|
||||||
|
last = -1
|
||||||
|
while True:
|
||||||
|
time.sleep(30)
|
||||||
|
code, lib = call("GET", f"/api/libraries/{lid}", token=token)
|
||||||
|
code2, one = call("GET", f"/api/libraries/{lid}/items?limit=1", token=token)
|
||||||
|
total = one.get("total", -1) if code2 == 200 else -1
|
||||||
|
fetching = lib.get("isFetchingLibrary") if code == 200 else None
|
||||||
|
print(f" fetching={fetching} items={total}")
|
||||||
|
if fetching is False and total == last and total >= 0:
|
||||||
|
break
|
||||||
|
last = total
|
||||||
|
print(f"scan settled at {total} items")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(token, lid):
|
||||||
|
missing = 0
|
||||||
|
seen, page = set(), 0
|
||||||
|
while True:
|
||||||
|
code, body = call("GET", f"/api/libraries/{lid}/items?page={page}&limit=500",
|
||||||
|
token=token)
|
||||||
|
results = body.get("results", []) if code == 200 else []
|
||||||
|
if not results:
|
||||||
|
break
|
||||||
|
for it in results:
|
||||||
|
if it["id"] not in seen:
|
||||||
|
seen.add(it["id"])
|
||||||
|
if it.get("isMissing"):
|
||||||
|
missing += 1
|
||||||
|
page += 1
|
||||||
|
code, lib = call("GET", f"/api/libraries/{lid}", token=token)
|
||||||
|
print(f"items: {len(seen)} isMissing: {missing} "
|
||||||
|
f"pending tasks: {lib.get('pendingTasks') if code == 200 else '?'}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
c = sub.add_parser("covers")
|
||||||
|
c.add_argument("--list", required=True)
|
||||||
|
w = sub.add_parser("wipe")
|
||||||
|
w.add_argument("--apply", action="store_true")
|
||||||
|
s = sub.add_parser("scan")
|
||||||
|
s.add_argument("--wait", action="store_true")
|
||||||
|
sub.add_parser("status")
|
||||||
|
args = ap.parse_args()
|
||||||
|
token = login()
|
||||||
|
lid = library_id(token)
|
||||||
|
if args.cmd == "covers":
|
||||||
|
cmd_covers(args)
|
||||||
|
elif args.cmd == "wipe":
|
||||||
|
cmd_wipe(args, token, lid)
|
||||||
|
elif args.cmd == "scan":
|
||||||
|
cmd_scan(args, token, lid)
|
||||||
|
else:
|
||||||
|
cmd_status(token, lid)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+311
@@ -0,0 +1,311 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audiobookshelf-init.sh — make an Audiobookshelf server usable, from nothing.
|
||||||
|
#
|
||||||
|
# Why this is a script and not a browser session (live 2026-11-02, Dankserver):
|
||||||
|
# a freshly deployed Audiobookshelf answers /healthcheck with 200 while it has no
|
||||||
|
# user, no library and no scan — "healthy" says nothing about usefulness, and a
|
||||||
|
# service that only works after someone remembers to click through a first-run
|
||||||
|
# wizard is not deployed, it is installed. Everything below is the API the React
|
||||||
|
# client itself calls, so the same procedure runs unattended and lands in git.
|
||||||
|
#
|
||||||
|
# The API facts this script depends on, read off the pinned image rather than
|
||||||
|
# guessed (dist-server/server/Server.js inside the digest this repo pins):
|
||||||
|
# * the creation route is POST /init with {"newRoot":{"username","password"}}.
|
||||||
|
# There is NO /setup route in 2.37.x — POST /setup answers "Unauthorized",
|
||||||
|
# which reads exactly like an auth fault and is instead a missing route.
|
||||||
|
# * /status (no /api prefix) reports {"isInit": <has a root user>}; every route
|
||||||
|
# under /api needs the bearer token that POST /login returns.
|
||||||
|
# * the library count is NOT a field on the library DTO; the door is
|
||||||
|
# GET /api/libraries/{id}/items -> {"total": N}.
|
||||||
|
#
|
||||||
|
# The credential is generated once into a mode-600 host file and never enters
|
||||||
|
# the repo, the URL, or a process argument list. Same pattern as calibre-server.
|
||||||
|
#
|
||||||
|
# Single sign-on (authentik) is armed by the same run, and only when the credential
|
||||||
|
# file carries ABS_OIDC_CLIENT_SECRET, so a local-only deployment is unchanged by a
|
||||||
|
# re-run. These OIDC facts came from the same pinned image, and each one is a trap:
|
||||||
|
# * isOpenIDAuthSettingsValid demands issuer + authorization + token + userinfo +
|
||||||
|
# jwks + clientID + clientSecret + signingAlgorithm at once. Miss one and the
|
||||||
|
# server SILENTLY strips 'openid' from authActiveAuthMethods and falls back to
|
||||||
|
# ['local'] while answering 200 everywhere — so the only honest check is what
|
||||||
|
# /status says AFTER the write, which is what this script does.
|
||||||
|
# * the algorithm is HS256, not the image's RS256 default: no provider on the
|
||||||
|
# fleet's authentik carries a signing key, so the client secret IS the signature
|
||||||
|
# key (scripts/authentik-audiobookshelf-sso.sh). Left at RS256, every id_token
|
||||||
|
# fails verification and the failure looks like a broken IdP.
|
||||||
|
# * the auth block lives on PATCH /api/auth-settings. PATCH /api/settings has a
|
||||||
|
# 20-key allowlist (PATCHABLE_SETTINGS_KEYS) with NO auth key in it, so writing
|
||||||
|
# the OIDC config there answers 200 and changes nothing — measured on this host
|
||||||
|
# 2026-11-02. The keys /api/auth-settings accepts are exactly its own
|
||||||
|
# authenticationSettings getters; anything else is skipped with a debug line.
|
||||||
|
# * authOpenIDSubfolderForRedirectURLs must be sent explicitly. The 2.17.4 migration only
|
||||||
|
# fills it on a DB that ALREADY had OIDC on, so a database created before SSO leaves it
|
||||||
|
# UNDEFINED and the server builds redirect_uri
|
||||||
|
# "http://host/undefined/auth/openid/callback" — authentik answers the authorize request
|
||||||
|
# with "Redirect URI Error" (measured 2026-11-02). This script derives the value from
|
||||||
|
# ABS_URL's path, so it cannot drift from the unit's ROUTER_BASE_PATH.
|
||||||
|
# * 'local' stays in authActiveAuthMethods on purpose: if authentik is ever down,
|
||||||
|
# the root account must still be able to sign into its own server.
|
||||||
|
#
|
||||||
|
# usage: audiobookshelf-init.sh # create what is missing, then scan
|
||||||
|
# ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan
|
||||||
|
# ABS_OIDC=0 audiobookshelf-init.sh # never touch the auth methods
|
||||||
|
#
|
||||||
|
# environment (defaults are the deployed shape):
|
||||||
|
# ABS_URL server base URL INCLUDING the mounted base path
|
||||||
|
# (http://127.0.0.1:8000/audiobookshelf)
|
||||||
|
# ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf)
|
||||||
|
# ABS_LIBRARY library name to ensure (Audiobooks)
|
||||||
|
# ABS_FOLDER folder to bind inside the server (/audiobooks)
|
||||||
|
# ABS_SCAN 1 triggers a scan (default), 0 skips it
|
||||||
|
# ABS_OIDC auto (default) arms SSO when the credential file has a client
|
||||||
|
# secret, 1 requires one, 0 leaves the auth methods alone
|
||||||
|
# ABS_OIDC_ISSUER authentik issuer URL for this client
|
||||||
|
# ABS_OIDC_CLIENT_ID provider client id (audiobookshelf)
|
||||||
|
# ABS_OIDC_ALG id_token signing algorithm the IdP actually uses (HS256)
|
||||||
|
# ABS_OIDC_SUBFOLDER override for the redirect-URI subfolder (default: ABS_URL's path)
|
||||||
|
#
|
||||||
|
# exit 0 every step is satisfied (created or already present)
|
||||||
|
# exit 1 a step was refused or the server misbehaved (named on stderr)
|
||||||
|
# exit 2 usage / unreadable credential file
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ABS_URL=${ABS_URL:-http://127.0.0.1:8000/audiobookshelf}
|
||||||
|
ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf}
|
||||||
|
ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks}
|
||||||
|
ABS_FOLDER=${ABS_FOLDER:-/audiobooks}
|
||||||
|
ABS_SCAN=${ABS_SCAN:-1}
|
||||||
|
ABS_OIDC=${ABS_OIDC:-auto}
|
||||||
|
ABS_OIDC_ISSUER=${ABS_OIDC_ISSUER:-https://auth.franksdank.cloud/application/o/audiobookshelf/}
|
||||||
|
ABS_OIDC_CLIENT_ID=${ABS_OIDC_CLIENT_ID:-audiobookshelf}
|
||||||
|
ABS_OIDC_ALG=${ABS_OIDC_ALG:-HS256}
|
||||||
|
|
||||||
|
die() { printf 'refused: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
case "$ABS_SCAN" in
|
||||||
|
0 | 1) ;;
|
||||||
|
*) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$ABS_OIDC" in
|
||||||
|
auto | 0 | 1) ;;
|
||||||
|
*) printf 'refused: ABS_OIDC wants auto, 0 or 1, got %s\n' "$ABS_OIDC" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ---- credential -------------------------------------------------------------
|
||||||
|
umask 077
|
||||||
|
if [ ! -f "$ABS_SECRET" ]; then
|
||||||
|
mkdir -p "$(dirname "$ABS_SECRET")"
|
||||||
|
user=${ABS_ADMIN_USER:-frank}
|
||||||
|
# `tr … | head -c 24` is the obvious line and it is a trap under
|
||||||
|
# `set -o pipefail`: head exits at 24 bytes, tr takes SIGPIPE, the pipeline
|
||||||
|
# status is 141 and set -e kills the script before the credential is ever
|
||||||
|
# written (measured 2026-11-02). od reads a FIXED byte count and exits on its
|
||||||
|
# own, so no consumer closes a pipe early; bash slices the length.
|
||||||
|
pass=$(od -An -tx1 -N 32 /dev/urandom | tr -d ' \n')
|
||||||
|
pass=${pass:0:24}
|
||||||
|
[ "${#pass}" -eq 24 ] || die "could not generate a password (no /dev/urandom)"
|
||||||
|
printf 'ABS_ADMIN_USER=%s\nABS_ADMIN_PASSWORD=%s\n' "$user" "$pass" > "$ABS_SECRET"
|
||||||
|
printf 'credential: created %s\n' "$ABS_SECRET"
|
||||||
|
fi
|
||||||
|
[ -r "$ABS_SECRET" ] || die "$ABS_SECRET exists but is not readable by $(id -un)"
|
||||||
|
chmod 600 "$ABS_SECRET"
|
||||||
|
|
||||||
|
# ---- everything else, over HTTP ---------------------------------------------
|
||||||
|
# python3 rather than curl so the password and the bearer token never appear in
|
||||||
|
# any argv (they would be world-readable in /proc/<pid>/cmdline otherwise).
|
||||||
|
ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \
|
||||||
|
ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" ABS_OIDC="$ABS_OIDC" \
|
||||||
|
ABS_OIDC_ISSUER="$ABS_OIDC_ISSUER" ABS_OIDC_CLIENT_ID="$ABS_OIDC_CLIENT_ID" \
|
||||||
|
ABS_OIDC_ALG="$ABS_OIDC_ALG" python3 - <<'PY'
|
||||||
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
BASE = os.environ["ABS_URL"].rstrip("/")
|
||||||
|
LIB = os.environ["ABS_LIBRARY"]
|
||||||
|
FOLDER = os.environ["ABS_FOLDER"]
|
||||||
|
SCAN = os.environ["ABS_SCAN"] == "1"
|
||||||
|
cred = {}
|
||||||
|
for line in open(os.environ["ABS_SECRET"]):
|
||||||
|
if "=" in line:
|
||||||
|
k, _, v = line.strip().partition("=")
|
||||||
|
cred[k] = v
|
||||||
|
user = cred.get("ABS_ADMIN_USER") or "frank"
|
||||||
|
password = cred.get("ABS_ADMIN_PASSWORD") or ""
|
||||||
|
if not password:
|
||||||
|
sys.exit("refused: %s has no ABS_ADMIN_PASSWORD" % os.environ["ABS_SECRET"])
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, body=None, token=None, want_json=True):
|
||||||
|
data = None
|
||||||
|
headers = {"X-Requested-With": "audiobookshelf-init"}
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = "Bearer " + token
|
||||||
|
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if not raw or not want_json:
|
||||||
|
return r.status, raw
|
||||||
|
try:
|
||||||
|
return r.status, json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
# Express answers sendStatus() with the STATUS TEXT as the body:
|
||||||
|
# POST /init and the scan route both reply "OK" (measured on the
|
||||||
|
# pinned 2.37.2 image). A successful call must never be judged by
|
||||||
|
# whether its body happens to parse as JSON.
|
||||||
|
return r.status, raw.decode("utf-8", "replace")[:200]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
payload = raw.decode("utf-8", "replace")[:200]
|
||||||
|
return e.code, payload
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
# Not an HTTP answer at all: nothing listening, DNS dead, connection
|
||||||
|
# refused. HTTPError is a URLError subclass, so this arm must come after.
|
||||||
|
return 0, "unreachable (%s)" % e.reason
|
||||||
|
|
||||||
|
|
||||||
|
status, st = call("GET", "/status")
|
||||||
|
if status != 200 or not isinstance(st, dict):
|
||||||
|
sys.exit("refused: %s/status answered %s (%s) — is the service up?" % (BASE, status, st))
|
||||||
|
if st.get("isInit"):
|
||||||
|
print("account: already registered")
|
||||||
|
else:
|
||||||
|
code, body = call("POST", "/init", {"newRoot": {"username": user, "password": password}})
|
||||||
|
if code != 200:
|
||||||
|
sys.exit("refused: POST /init answered %s (%s)" % (code, body))
|
||||||
|
print("account: created (%s)" % user)
|
||||||
|
|
||||||
|
code, login = call("POST", "/login", {"username": user, "password": password})
|
||||||
|
if code != 200 or not isinstance(login, dict) or not login.get("user", {}).get("token"):
|
||||||
|
sys.exit("refused: login answered %s (%s) — the credential file does not match the server"
|
||||||
|
% (code, login))
|
||||||
|
token = login["user"]["token"]
|
||||||
|
print("token: obtained (%s)" % login["user"].get("type", "user"))
|
||||||
|
|
||||||
|
code, libs = call("GET", "/api/libraries", token=token)
|
||||||
|
if code != 200 or not isinstance(libs, dict) or "libraries" not in libs:
|
||||||
|
sys.exit("refused: GET /api/libraries answered %s (%s)" % (code, libs))
|
||||||
|
lib = next((l for l in libs["libraries"] if l.get("name") == LIB), None)
|
||||||
|
if lib:
|
||||||
|
print("library: exists %s (%s)" % (LIB, lib["id"]))
|
||||||
|
else:
|
||||||
|
body = {
|
||||||
|
"name": LIB,
|
||||||
|
"provider": "audiobook",
|
||||||
|
"icon": "book",
|
||||||
|
"folders": [{"fullPath": FOLDER}],
|
||||||
|
"settings": {
|
||||||
|
"coverEncoding": "jpg",
|
||||||
|
"skipMatchingMediaWithAsin": False,
|
||||||
|
"skipMatchingMediaWithIsbn": False,
|
||||||
|
"autoDetectCrossoverAudiobooks": False,
|
||||||
|
"ignoreAudiobookMetadata": False,
|
||||||
|
"useAudiobookFileMetadata": False,
|
||||||
|
},
|
||||||
|
"providerMetadata": {},
|
||||||
|
}
|
||||||
|
code, made = call("POST", "/api/libraries", body, token=token)
|
||||||
|
if code != 200:
|
||||||
|
sys.exit("refused: POST /api/libraries answered %s (%s)" % (code, made))
|
||||||
|
lib = made
|
||||||
|
print("library: created %s (%s) folder %s" % (LIB, lib["id"], FOLDER))
|
||||||
|
|
||||||
|
if SCAN:
|
||||||
|
code, body = call("POST", "/api/libraries/%s/scan" % lib["id"], {}, token=token,
|
||||||
|
want_json=False)
|
||||||
|
if code != 200:
|
||||||
|
sys.exit("refused: scan answered %s (%s)" % (code, body))
|
||||||
|
print("scan: accepted")
|
||||||
|
else:
|
||||||
|
print("scan: skipped (ABS_SCAN=0)")
|
||||||
|
|
||||||
|
code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=token)
|
||||||
|
if code == 200 and isinstance(items, dict):
|
||||||
|
print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)"
|
||||||
|
% items.get("total"))
|
||||||
|
|
||||||
|
# ---- single sign-on (authentik) --------------------------------------------------
|
||||||
|
# The provider this points at is provisioned by scripts/authentik-audiobookshelf-sso.sh;
|
||||||
|
# the two share exactly one value, the client secret in the credential file.
|
||||||
|
mode = os.environ["ABS_OIDC"]
|
||||||
|
oidc_secret = cred.get("ABS_OIDC_CLIENT_SECRET") or ""
|
||||||
|
if mode == "0":
|
||||||
|
print("oidc: skipped (ABS_OIDC=0)")
|
||||||
|
elif not oidc_secret:
|
||||||
|
if mode == "1":
|
||||||
|
sys.exit("refused: ABS_OIDC=1 but %s has no ABS_OIDC_CLIENT_SECRET — run "
|
||||||
|
"scripts/authentik-audiobookshelf-sso.sh first" % os.environ["ABS_SECRET"])
|
||||||
|
print("oidc: not configured (no ABS_OIDC_CLIENT_SECRET in the credential file)"
|
||||||
|
" — local auth only")
|
||||||
|
else:
|
||||||
|
client_id = os.environ["ABS_OIDC_CLIENT_ID"]
|
||||||
|
issuer = os.environ["ABS_OIDC_ISSUER"].rstrip("/") + "/"
|
||||||
|
# redirect_uri = <origin><subfolder>/auth/openid/callback, and the subfolder IS the
|
||||||
|
# base path the app is mounted under — taken from ABS_URL so unit and config agree.
|
||||||
|
subfolder = os.environ.get("ABS_OIDC_SUBFOLDER")
|
||||||
|
if subfolder is None:
|
||||||
|
subfolder = urllib.parse.urlparse(os.environ["ABS_URL"]).path.rstrip("/")
|
||||||
|
origin = issuer.split("/application/")[0]
|
||||||
|
body = {
|
||||||
|
"authActiveAuthMethods": ["local", "openid"],
|
||||||
|
"authOpenIDIssuerURL": issuer,
|
||||||
|
"authOpenIDAuthorizationURL": origin + "/application/o/authorize/",
|
||||||
|
"authOpenIDTokenURL": origin + "/application/o/token/",
|
||||||
|
"authOpenIDUserInfoURL": origin + "/application/o/userinfo/",
|
||||||
|
"authOpenIDJwksURL": issuer + "jwks/",
|
||||||
|
"authOpenIDLogoutURL": issuer + "end-session/",
|
||||||
|
"authOpenIDClientID": client_id,
|
||||||
|
"authOpenIDClientSecret": oidc_secret,
|
||||||
|
"authOpenIDTokenSigningAlgorithm": os.environ["ABS_OIDC_ALG"],
|
||||||
|
"authOpenIDSubfolderForRedirectURLs": subfolder,
|
||||||
|
"authOpenIDButtonText": "Sign in with Authentik",
|
||||||
|
# not auto-launch: the login page keeps offering the local form, which is the
|
||||||
|
# break-glass path when the IdP is unreachable.
|
||||||
|
"authOpenIDAutoLaunch": False,
|
||||||
|
# a household user's first SSO sign-in creates their account. The role comes from
|
||||||
|
# the abs_groups claim, which grants the listener role to every signer; only the
|
||||||
|
# authentik admin group escalates. Revoking a listener therefore happens HERE in
|
||||||
|
# ABS (disable or delete the user), never by removing an IdP group membership.
|
||||||
|
"authOpenIDAutoRegister": True,
|
||||||
|
"authOpenIDGroupClaim": "abs_groups",
|
||||||
|
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
|
||||||
|
# <base>/auth/openid/mobile-redirect before the provider sees a redirect_uri.
|
||||||
|
"authOpenIDMobileRedirectURIs": ["audiobookshelf://oauth"],
|
||||||
|
}
|
||||||
|
code, res = call("PATCH", "/api/auth-settings", body, token=token)
|
||||||
|
if code != 200:
|
||||||
|
sys.exit("refused: PATCH /api/auth-settings answered %s (%s)" % (code, res))
|
||||||
|
# Verify twice: what was stored, and what the server will honour. A config that
|
||||||
|
# misses any of the eight required OIDC values is accepted with 200 and then
|
||||||
|
# silently dropped at the next load, leaving authMethods=['local'].
|
||||||
|
code, stored = call("GET", "/api/auth-settings", token=token)
|
||||||
|
if code != 200 or not isinstance(stored, dict):
|
||||||
|
sys.exit("refused: GET /api/auth-settings answered %s (%s)" % (code, stored))
|
||||||
|
for key, want in (("authOpenIDIssuerURL", issuer),
|
||||||
|
("authOpenIDTokenSigningAlgorithm", os.environ["ABS_OIDC_ALG"]),
|
||||||
|
("authOpenIDSubfolderForRedirectURLs", subfolder),
|
||||||
|
("authOpenIDButtonText", "Sign in with Authentik")):
|
||||||
|
if stored.get(key) != want:
|
||||||
|
sys.exit("refused: stored %s=%r, expected %r" % (key, stored.get(key), want))
|
||||||
|
if sorted(stored.get("authActiveAuthMethods") or []) != ["local", "openid"]:
|
||||||
|
sys.exit("refused: stored authActiveAuthMethods=%r, expected local+openid "
|
||||||
|
"('openid' is dropped when the block is incomplete)"
|
||||||
|
% stored.get("authActiveAuthMethods"))
|
||||||
|
code, after = call("GET", "/status")
|
||||||
|
methods = after.get("authMethods") if isinstance(after, dict) else None
|
||||||
|
if not isinstance(methods, list) or "openid" not in methods:
|
||||||
|
sys.exit("refused: /status reports authMethods=%s after a stored config that "
|
||||||
|
"looks complete — restart the service, the strategy is only armed at "
|
||||||
|
"load for a config that was invalid when the server read it" % methods)
|
||||||
|
print("oidc: armed (issuer %s | alg %s | claim abs_groups | redirect subfolder %r | "
|
||||||
|
"client %s | secret %s chars | authMethods %s)"
|
||||||
|
% (issuer, os.environ["ABS_OIDC_ALG"], subfolder, client_id, len(oidc_secret),
|
||||||
|
methods))
|
||||||
|
PY
|
||||||
|
exit 0
|
||||||
Executable
+294
@@ -0,0 +1,294 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# authentik-audiobookshelf-sso.sh — provision the authentik side of Audiobookshelf SSO.
|
||||||
|
#
|
||||||
|
# Why a script: this state lives in authentik's DATABASE, not in any repo file, and a
|
||||||
|
# blueprint re-import or a rebuild-from-scratch reverts hand-made provider rows
|
||||||
|
# silently. Same reasoning (and same door) as
|
||||||
|
# authentik-set-login-session-duration.sh: ssh into Dankclaw, `ak shell` from stdin.
|
||||||
|
# Re-run this after such a rebuild.
|
||||||
|
#
|
||||||
|
# The contract with Audiobookshelf, read off the pinned image (2.37.2,
|
||||||
|
# dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js)
|
||||||
|
# rather than a forum, because each of these is a dead-login trap:
|
||||||
|
#
|
||||||
|
# * Two URIs per origin ABS is reached at — <base>/auth/openid/callback and
|
||||||
|
# <base>/auth/openid/mobile-redirect — for EVERY entry in BASES (the LAN address
|
||||||
|
# and the public vhost). ABS SWAPS the app's own audiobookshelf://oauth for the
|
||||||
|
# mobile-redirect URL before the IdP ever sees a redirect_uri, so registering the
|
||||||
|
# app scheme with authentik — the obvious move — is what makes phone logins fail.
|
||||||
|
# The app scheme belongs in ABS's authOpenIDMobileRedirectURIs list (its built-in
|
||||||
|
# default), not here. The mobile URI exists on every origin because the app sends
|
||||||
|
# the one belonging to the door it was configured with: the vhost started serving
|
||||||
|
# non-browser clients on 2026-11-03 (it left the guard chain), so a phone pointed
|
||||||
|
# at the public name sends the public mobile-redirect URI, and STRICT matching
|
||||||
|
# answers an unregistered one with "Redirect URI Error".
|
||||||
|
# * <base> CARRIES THE BASE PATH. ABS builds redirect_uri as
|
||||||
|
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and that subfolder is
|
||||||
|
# the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only
|
||||||
|
# base path the shipped SPA can boot under. Left unset (the fresh-DB default) the URL
|
||||||
|
# becomes "/undefined/…"; a provider registered without the prefix mismatches the same way
|
||||||
|
# (measured 2026-11-02, "Redirect URI Error"). audiobookshelf-init.sh derives its copy from
|
||||||
|
# ABS_URL, so the two sides each move with one variable.
|
||||||
|
# * ABS requests the scopes "openid profile email <GroupClaim> [<PermsClaim>]": the
|
||||||
|
# CLAIM NAME is a scope name. So authentik needs a scope mapping whose scope_name
|
||||||
|
# is exactly the claim name, or the claim never arrives and the callback dies on
|
||||||
|
# "Group claim <name> not found in userinfo".
|
||||||
|
# * the claim's VALUES must be ABS role names — the callback picks the first of
|
||||||
|
# ['admin','user','guest'] present in the list. Group names like
|
||||||
|
# audiobookshelf_admins are therefore translated by the mapping, never passed
|
||||||
|
# through. A user in neither group gets [] and is denied (fail closed).
|
||||||
|
# * tokens are HS256 here: no provider on this box carries a signing_key (census
|
||||||
|
# 2026-11-02), which makes the client secret the signature key. ABS's own default
|
||||||
|
# is RS256, so the ABS side MUST be set to HS256 or every id_token fails
|
||||||
|
# verification.
|
||||||
|
# * provider.authorization_flow must be non-NULL or /application/o/authorize/
|
||||||
|
# answers a 500 with 'NoneType' object has no attribute 'slug' (measured on
|
||||||
|
# grafana, same authentik build).
|
||||||
|
# * provider.grant_types MUST contain authorization_code. authentik 2026.8 defaults an
|
||||||
|
# ORM-created provider to the EMPTY list, and /application/o/authorize/ then refuses the
|
||||||
|
# code flow with oauthlib's generic "The request is otherwise malformed"; the only line
|
||||||
|
# that names the cause is the server log's 'Invalid grant_type for provider'. The
|
||||||
|
# redirect-URI check runs FIRST, so a provider carrying both faults shows only the URI
|
||||||
|
# error until the URI is fixed (measured 2026-11-02, where the login survived the URI fix
|
||||||
|
# and died here).
|
||||||
|
# * property_mappings must carry the built-in OpenID scope mappings ('openid', 'profile',
|
||||||
|
# 'email') alongside the claim mapping. A provider holding only the claim mapping grants a
|
||||||
|
# token scoped shorter than the scopes ABS requests, and ABS's /userinfo call then dies
|
||||||
|
# 'insufficient_scope (The request requires higher privileges than provided by the access
|
||||||
|
# token)' after authentik has already issued a good code (measured 2026-11-02).
|
||||||
|
#
|
||||||
|
# Idempotent: every object is created or asserted. Safe to re-run.
|
||||||
|
#
|
||||||
|
# usage: ABS_OIDC_CLIENT_SECRET=<secret> authentik-audiobookshelf-sso.sh
|
||||||
|
# (the secret is the value stored in
|
||||||
|
# /home/dank/.config/secrets/audiobookshelf on Dankserver — get it with
|
||||||
|
# ssh dankserver awk -F= '/^ABS_OIDC_CLIENT_SECRET=/{print $2}' \
|
||||||
|
# ~/.config/secrets/audiobookshelf)
|
||||||
|
#
|
||||||
|
# environment:
|
||||||
|
# ABS_OIDC_CLIENT_SECRET REQUIRED. The provider's client secret.
|
||||||
|
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
|
||||||
|
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
|
||||||
|
# ABS_OIDC_USERNAME authentik user made admin (akadmin)
|
||||||
|
# There is deliberately NO user list. The claim grants the LISTENER role to every
|
||||||
|
# authentik account that signs in (2026-11-03), so onboarding a listener is exactly
|
||||||
|
# "create the authentik account". Before this, a household account authenticated at
|
||||||
|
# the IdP and was then refused by ABS for being in no group — a policy decision that
|
||||||
|
# read as a broken login. Revocation lives on the ABS side (disable or delete the
|
||||||
|
# user there): a group can no longer withhold a role the claim always sends.
|
||||||
|
# ABS_E2E_ADMIN authentik user kept in the ADMIN group for the Playwright gate
|
||||||
|
# (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts
|
||||||
|
# signs in as this identity, so a re-run restores the fixture)
|
||||||
|
# ABS_BASE ABS URL as the browser sees it, base path included
|
||||||
|
# (http://192.168.0.235:8000/audiobookshelf — the LAN
|
||||||
|
# origin, reached directly, which is also what the phone
|
||||||
|
# app is configured with)
|
||||||
|
# ABS_EXTRA_BASES space-separated additional origins, base path included
|
||||||
|
# (default: https://audiobooks.franksdank.cloud/audiobookshelf
|
||||||
|
# — the guarded vhost, dankclaw/Caddyfile).
|
||||||
|
# ABS builds its redirect_uri from the ORIGIN of the
|
||||||
|
# request, so each origin the app is served at needs its
|
||||||
|
# own registered URI — an unregistered origin is a
|
||||||
|
# redirect_uri_mismatch at the IdP, before any credential.
|
||||||
|
# Set it empty to register the LAN origin alone.
|
||||||
|
# AUTHENTIK_HOST ssh target (dankclaw)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}"
|
||||||
|
[ "${#SECRET}" -ge 32 ] || { echo "refused: ABS_OIDC_CLIENT_SECRET is too short to be a secret" >&2; exit 2; }
|
||||||
|
|
||||||
|
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
|
||||||
|
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
|
||||||
|
ADMIN_GROUP="audiobookshelf_admins"
|
||||||
|
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
||||||
|
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
|
||||||
|
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
|
||||||
|
EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}"
|
||||||
|
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||||
|
|
||||||
|
# The claim expression: every signer is a listener; only the admin group escalates.
|
||||||
|
# Stored verbatim so a later read of the provider shows exactly this policy. The default
|
||||||
|
# lives HERE and nowhere else — ABS reads this list off the token, so an account in no
|
||||||
|
# Audiobookshelf group still arrives with ["user"] and is auto-registered as a listener
|
||||||
|
# instead of being refused after a successful authentication.
|
||||||
|
read -r -d '' EXPR <<PYEXPR || true
|
||||||
|
groups = {g.name for g in user.groups.all()}
|
||||||
|
# Order is deliberate: an admin's claim comes out ["admin","user"], byte-identical to what
|
||||||
|
# this mapping emitted before the default existed, because ABS's group-tag matching has never
|
||||||
|
# been measured for order-sensitivity and the working admin login is not the place to find out.
|
||||||
|
roles = []
|
||||||
|
if "$ADMIN_GROUP" in groups:
|
||||||
|
roles.append("admin")
|
||||||
|
roles.append("user")
|
||||||
|
return {"$CLAIM": roles}
|
||||||
|
PYEXPR
|
||||||
|
|
||||||
|
# NOTE: the heredoc below is UNQUOTED on purpose — its body interpolates the variables
|
||||||
|
# above. The consequence is that a backtick anywhere in that python body is a LOCAL command
|
||||||
|
# substitution whose output is spliced into the program before it ever runs, and comments
|
||||||
|
# are not exempt, because bash does not know what a python comment is. Write names plainly
|
||||||
|
# in the body: measured 2026-11-03, two comments in this body had the shell run
|
||||||
|
# audiobookshelf_users and user as commands on every execution of this script.
|
||||||
|
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||||
|
from authentik.core.models import Application, Group, User
|
||||||
|
from authentik.flows.models import Flow
|
||||||
|
from authentik.providers.oauth2.models import (
|
||||||
|
ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping)
|
||||||
|
|
||||||
|
ADMIN_GROUP = "$ADMIN_GROUP"
|
||||||
|
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
|
||||||
|
BASES = [BASE] + """$EXTRA_BASES""".split()
|
||||||
|
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
|
||||||
|
EXPR = '''$EXPR'''
|
||||||
|
# The expression reads user.groups, NOT user.ak_groups: authentik 2026.8 logs
|
||||||
|
# "User.ak_groups is deprecated … use User.groups" on every evaluation of the old
|
||||||
|
# name (measured on this run), and a stored mapping that leans on a deprecated
|
||||||
|
# accessor is a future upgrade failure sitting inside the login path.
|
||||||
|
|
||||||
|
Group.objects.get_or_create(name=ADMIN_GROUP)
|
||||||
|
admin_g = Group.objects.get(name=ADMIN_GROUP)
|
||||||
|
# The audiobookshelf_users group is retired. Its membership used to be the only door to the
|
||||||
|
# listener role; the claim now sends that role to every signer, so the group decides
|
||||||
|
# nothing — and a group that decides nothing is a trap for the next reader, who will
|
||||||
|
# assume it gates something and start adding people to it.
|
||||||
|
for stale in Group.objects.filter(name="audiobookshelf_users"):
|
||||||
|
stale.delete()
|
||||||
|
print("group: retired audiobookshelf_users (the claim grants every signer)")
|
||||||
|
|
||||||
|
# --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
|
||||||
|
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
|
||||||
|
if mapping is None:
|
||||||
|
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
|
||||||
|
expression=EXPR,
|
||||||
|
description="Audiobookshelf roles: every signer is a listener, the admin group escalates")
|
||||||
|
print("mapping: created (scope_name %s)" % CLAIM)
|
||||||
|
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
|
||||||
|
mapping.scope_name, mapping.expression = CLAIM, EXPR
|
||||||
|
mapping.save(update_fields=["scope_name", "expression"])
|
||||||
|
print("mapping: scope/expression updated (scope_name %s)" % CLAIM)
|
||||||
|
else:
|
||||||
|
print("mapping: already current")
|
||||||
|
|
||||||
|
# Prove the expression runs and yields an ABS role BEFORE anyone tries to log in:
|
||||||
|
# authentik execs the expression inside a function whose context has the name "user".
|
||||||
|
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
|
||||||
|
_ns = {}
|
||||||
|
exec(_src, _ns)
|
||||||
|
|
||||||
|
# --- provider --------------------------------------------------------------------
|
||||||
|
# One callback pair per origin (LAN address and the public vhost): ABS derives the
|
||||||
|
# browser callback from the request origin and the mobile callback from the door the
|
||||||
|
# app was configured with, so each origin needs both or that door cannot sign in.
|
||||||
|
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url="%s%s" % (b, p))
|
||||||
|
for b in BASES
|
||||||
|
for p in ("/auth/openid/callback", "/auth/openid/mobile-redirect")]
|
||||||
|
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
|
||||||
|
|
||||||
|
# Three things beyond the name, each one a silent dead login: the grant-type list (2026.8
|
||||||
|
# defaults it empty), the built-in OpenID scope mappings beside the claim mapping, and the
|
||||||
|
# redirect URIs with the base path baked in.
|
||||||
|
GRANTS = ["authorization_code", "refresh_token"]
|
||||||
|
BUILTIN_SCOPES = ["authentik default OAuth Mapping: OpenID 'openid'",
|
||||||
|
"authentik default OAuth Mapping: OpenID 'profile'",
|
||||||
|
"authentik default OAuth Mapping: OpenID 'email'"]
|
||||||
|
scoped = {m.pk: m for m in ScopeMapping.objects.filter(name__in=BUILTIN_SCOPES)}
|
||||||
|
scoped[mapping.pk] = mapping
|
||||||
|
_have = {m.name for m in scoped.values()}
|
||||||
|
if len(_have) < len(BUILTIN_SCOPES):
|
||||||
|
print("scope mappings: NOT PROVIDED by this authentik install: %s"
|
||||||
|
% ", ".join(sorted(set(BUILTIN_SCOPES) - _have)))
|
||||||
|
|
||||||
|
p = OAuth2Provider.objects.filter(name="audiobookshelf").first()
|
||||||
|
if p is None:
|
||||||
|
p = OAuth2Provider(name="audiobookshelf", client_id=CLIENT_ID,
|
||||||
|
client_type=ClientType.CONFIDENTIAL, client_secret=SECRET,
|
||||||
|
authorization_flow=authz_flow, grant_types=list(GRANTS))
|
||||||
|
p.redirect_uris = uris
|
||||||
|
p.save()
|
||||||
|
p.property_mappings.set(list(scoped.values()))
|
||||||
|
print("provider: created (grant_types %s)" % GRANTS)
|
||||||
|
else:
|
||||||
|
changed = []
|
||||||
|
if p.client_id != CLIENT_ID:
|
||||||
|
p.client_id = CLIENT_ID
|
||||||
|
changed.append("client_id")
|
||||||
|
if p.client_secret != SECRET:
|
||||||
|
p.client_secret = SECRET
|
||||||
|
changed.append("client_secret")
|
||||||
|
if p.authorization_flow_id != authz_flow.pk:
|
||||||
|
p.authorization_flow = authz_flow
|
||||||
|
changed.append("authorization_flow")
|
||||||
|
if list(p.grant_types) != GRANTS:
|
||||||
|
p.grant_types = list(GRANTS)
|
||||||
|
changed.append("grant_types")
|
||||||
|
if [(u.url, str(u.matching_mode)) for u in p.redirect_uris] != \
|
||||||
|
[(u.url, str(u.matching_mode)) for u in uris]:
|
||||||
|
p.redirect_uris = uris
|
||||||
|
changed.append("redirect_uris")
|
||||||
|
if changed:
|
||||||
|
p.save()
|
||||||
|
if sorted(m.pk for m in p.property_mappings.all()) != sorted(scoped):
|
||||||
|
p.property_mappings.set(list(scoped.values()))
|
||||||
|
changed.append("property_mappings")
|
||||||
|
print("provider: asserted (%s)" % (", ".join(changed) or "nothing to change"))
|
||||||
|
|
||||||
|
# --- application (the launchpad entry; binds the provider) ------------------------
|
||||||
|
app = Application.objects.filter(slug="audiobookshelf").first()
|
||||||
|
if app is None:
|
||||||
|
app = Application.objects.create(name="audiobookshelf", slug="audiobookshelf",
|
||||||
|
provider=p, meta_launch_url=BASE)
|
||||||
|
print("application: created")
|
||||||
|
elif app.provider_id != p.pk or app.meta_launch_url != BASE:
|
||||||
|
app.provider, app.meta_launch_url = p, BASE
|
||||||
|
app.save(update_fields=["provider", "meta_launch_url"])
|
||||||
|
print("application: asserted")
|
||||||
|
else:
|
||||||
|
print("application: already bound")
|
||||||
|
|
||||||
|
# --- membership --------------------------------------------------------------------
|
||||||
|
root = User.objects.get(username=SSO_USER)
|
||||||
|
root.groups.add(admin_g)
|
||||||
|
# The deploy gate's Playwright spec signs in as this identity, so the admin fixture is
|
||||||
|
# provisioned with the rest: a blueprint re-import that drops it fails the gate, not a phone.
|
||||||
|
e2e = User.objects.filter(username=E2E_ADMIN).first()
|
||||||
|
if e2e is None:
|
||||||
|
print("e2e fixture: authentik user %s absent — the e2e SSO spec will fail" % E2E_ADMIN)
|
||||||
|
else:
|
||||||
|
e2e.groups.add(admin_g)
|
||||||
|
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
|
||||||
|
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
|
||||||
|
# The default is printed at provision time, not merely asserted: an expression that regressed
|
||||||
|
# to returning nothing for a non-member refuses every household login, and this is where a
|
||||||
|
# re-run notices it. The population matters — authentik ships an AnonymousUser row, a
|
||||||
|
# superuser, and ak-outpost-* service identities, and proving the default on one of those says
|
||||||
|
# nothing about the friend who logs in. Prefer the fleet's plain-user marker group (owned by
|
||||||
|
# serverAppConfigs-docs/Infrastructure/Authentik.md; a preference only, since that group
|
||||||
|
# belongs to other consumers), which normally lands on qa-e2e — the identity the e2e gate
|
||||||
|
# signs in as. is_superuser is a python property on authentik's User, not a queryable field.
|
||||||
|
pool = User.objects.filter(is_active=True).exclude(groups__name__in=[ADMIN_GROUP])
|
||||||
|
pool = pool.exclude(username__in=[root.username, E2E_ADMIN, "AnonymousUser"])
|
||||||
|
pool = [u for u in pool.order_by("username")
|
||||||
|
if not u.is_superuser and not u.username.startswith("ak-outpost-")]
|
||||||
|
household = [u for u in pool if any(g.name == "Non-Admin Users" for g in u.groups.all())]
|
||||||
|
for u in (household or pool)[:3]:
|
||||||
|
print("default probe: %s %s -> %s" % (
|
||||||
|
u.username, sorted(g.name for g in u.groups.all()),
|
||||||
|
_ns["_probe"](user=u, request=None)))
|
||||||
|
|
||||||
|
# --- verification: the claim the callback will actually receive ---------------------
|
||||||
|
for probe in [root] + ([e2e] if e2e else []):
|
||||||
|
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
|
||||||
|
p.refresh_from_db()
|
||||||
|
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
|
||||||
|
p.name, p.client_id, p.authorization_flow.slug, list(p.grant_types),
|
||||||
|
[u.url for u in p.redirect_uris]))
|
||||||
|
print("scopes: %s" % sorted(m.scopemapping.scope_name for m in p.property_mappings.all()))
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
cat <<DONE
|
||||||
|
|
||||||
|
Next: arm the Audiobookshelf side (issuer, client, HS256, claim) —
|
||||||
|
ssh dankserver 'ABS_OIDC=1 bash /home/dank/serverAppConfigs/scripts/audiobookshelf-init.sh'
|
||||||
|
It reads the same secret from /home/dank/.config/secrets/audiobookshelf and verifies
|
||||||
|
/status answers openid among its auth methods.
|
||||||
|
DONE
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Census and repair the OAuth2 defaults on every authentik PROXY provider.
|
||||||
|
#
|
||||||
|
# A proxy provider built by hand in `ak shell` never runs
|
||||||
|
# ProxyProvider.set_oauth_defaults() — the REST serializer calls it, the ORM does
|
||||||
|
# not — so the row looks perfectly configured (external_host, internal_host,
|
||||||
|
# application bound, embedded-outpost claim present) while three OAuth facts are
|
||||||
|
# blank. Each one kills the guarded login in a different, unrelated-looking way,
|
||||||
|
# all BEFORE any credential is asked for:
|
||||||
|
#
|
||||||
|
# * redirect_uris empty -> authentik answers its own page:
|
||||||
|
# "Redirect URI Error — The request fails due to a missing, invalid, or
|
||||||
|
# mismatching redirection URI (redirect_uri)". Measured on the Audiobookshelf
|
||||||
|
# guard provider, 2026-11-03.
|
||||||
|
# * grant_types empty -> the authorize endpoint refuses the outpost's
|
||||||
|
# authorization-code request with a bodiless HTTP 400 (Chromium renders
|
||||||
|
# "This page isn't working / HTTP ERROR 400"). Same provider, one step
|
||||||
|
# later, after the URIs were filled in by hand.
|
||||||
|
# * authorization_flow NULL -> the authorize view crashes building its flow
|
||||||
|
# planner: AttributeError 'NoneType' object has no attribute 'slug', HTTP
|
||||||
|
# 500. That is what authentik-grafana-sso-fix.sh exists for.
|
||||||
|
#
|
||||||
|
# So: a guard that shows ANY of those three pages is not an Anubis problem and
|
||||||
|
# not a Caddy problem — run this script before touching the route.
|
||||||
|
#
|
||||||
|
# Repair calls the VENDOR's own set_oauth_defaults() (grant_types = authz code +
|
||||||
|
# client credentials + password, confidential client, the stock scope mappings,
|
||||||
|
# redirect_uris derived from external_host by _get_callback_url) and points the
|
||||||
|
# authorization flow at default-provider-authorization-implicit-consent, the flow
|
||||||
|
# every working provider here uses. Providers that already match are untouched,
|
||||||
|
# so a deliberate per-provider deviation is never silently flattened.
|
||||||
|
#
|
||||||
|
# This is authentik DB state. It is NOT in any deployable file: a blueprint
|
||||||
|
# re-import, or an ak-shell re-creation of a provider, reverts these facts and
|
||||||
|
# the symptom returns verbatim. Re-run after any such rebuild. Idempotent.
|
||||||
|
#
|
||||||
|
# Usage: authentik-proxy-provider-defaults.sh [--check]
|
||||||
|
# no argument repair what drifts, print one line per provider
|
||||||
|
# --check census only, exit 1 if anything drifts (gate use)
|
||||||
|
# override host: AUTHENTIK_HOST=…
|
||||||
|
set -euo pipefail
|
||||||
|
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||||
|
MODE="${1:-}"
|
||||||
|
case "$MODE" in
|
||||||
|
"" | --check) : ;;
|
||||||
|
*) echo "usage: $(basename "$0") [--check]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||||
|
from authentik.flows.models import Flow
|
||||||
|
from authentik.providers.proxy.models import ProxyProvider, _get_callback_url
|
||||||
|
|
||||||
|
CHECK = $( [ "$MODE" = "--check" ] && echo True || echo False )
|
||||||
|
FLOW = "default-provider-authorization-implicit-consent"
|
||||||
|
WANT_GRANTS = ["authorization_code", "client_credentials", "password"]
|
||||||
|
|
||||||
|
flow = Flow.objects.filter(slug=FLOW).first()
|
||||||
|
if not flow and not CHECK:
|
||||||
|
raise SystemExit("flow %s is missing; nothing was changed" % FLOW)
|
||||||
|
|
||||||
|
drift = 0
|
||||||
|
for p in ProxyProvider.objects.all().order_by("pk"):
|
||||||
|
want_uris = [u.url for u in _get_callback_url(p.external_host)]
|
||||||
|
have_uris = [u.url for u in p.redirect_uris]
|
||||||
|
bad = []
|
||||||
|
if [str(g) for g in p.grant_types] != WANT_GRANTS:
|
||||||
|
bad.append("grant_types=%s" % [str(g) for g in p.grant_types])
|
||||||
|
if have_uris != want_uris:
|
||||||
|
bad.append("redirect_uris=%s" % have_uris)
|
||||||
|
if p.authorization_flow_id is None:
|
||||||
|
bad.append("authorization_flow=None")
|
||||||
|
if not bad:
|
||||||
|
print("ok %s (ext %s)" % (p.name, p.external_host))
|
||||||
|
continue
|
||||||
|
drift += 1
|
||||||
|
if CHECK:
|
||||||
|
print("DRIFT %s: %s" % (p.name, "; ".join(bad)))
|
||||||
|
continue
|
||||||
|
print("fix %s: %s" % (p.name, "; ".join(bad)))
|
||||||
|
p.set_oauth_defaults()
|
||||||
|
if p.authorization_flow_id is None:
|
||||||
|
p.authorization_flow = flow
|
||||||
|
p.save()
|
||||||
|
after = ProxyProvider.objects.get(pk=p.pk)
|
||||||
|
print(" -> grants %s, %d redirect URIs, flow %s"
|
||||||
|
% ([str(g) for g in after.grant_types], len(after.redirect_uris),
|
||||||
|
after.authorization_flow and after.authorization_flow.slug))
|
||||||
|
|
||||||
|
print("providers checked, drift: %d" % drift)
|
||||||
|
raise SystemExit(1 if (CHECK and drift) else 0)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
echo "Verify: walk the guarded vhost in a browser — Anubis solves, authentik"
|
||||||
|
echo "renders its login form (no Redirect URI Error, no HTTP 400), and the"
|
||||||
|
echo "callback lands back on the app."
|
||||||
+149
-18
@@ -22,6 +22,17 @@ Mechanics notes:
|
|||||||
prompts do not bias the Hessians (the corpus is drawn round-robin over
|
prompts do not bias the Hessians (the corpus is drawn round-robin over
|
||||||
source classes, so loss is class-neutral as long as failures are rare;
|
source classes, so loss is class-neutral as long as failures are rare;
|
||||||
a systematic 4xx is loud in the log and must be fixed, not ignored).
|
a systematic 4xx is loud in the log and must be fixed, not ignored).
|
||||||
|
- a round that seals no tap bytes is NORMAL and continues: RADIANCE_CALIB_TOKENS
|
||||||
|
is a pooled floor and this campaign's corpus is 10.03M tokens against a 12M
|
||||||
|
floor, so round 1 seals nothing by construction. A tap that LOSES bytes fails
|
||||||
|
the run, and a run that never sees the engine's completion line exits non-zero.
|
||||||
|
The first version failed on a flat round, which aborted exactly one round too
|
||||||
|
early (2026-10-29).
|
||||||
|
- before any prompt, the tap must carry the rig's own marker
|
||||||
|
(`--tap-marker`, written by run-qwen38-bf16.sh calib) naming THIS
|
||||||
|
container, and that container must be running. Measured 2026-10-29:
|
||||||
|
with neither check, four rounds of 10M tokens went into restored
|
||||||
|
production after the rig's boot had died, and recorded nothing.
|
||||||
|
|
||||||
Usage: calib-push.py [--port 7770] [--parallel 64] [--container arbench]
|
Usage: calib-push.py [--port 7770] [--parallel 64] [--container arbench]
|
||||||
[--corpus /home/dank/corpora/out/calib.jsonl] [--max-rounds 4]
|
[--corpus /home/dank/corpora/out/calib.jsonl] [--max-rounds 4]
|
||||||
@@ -29,11 +40,18 @@ Usage: calib-push.py [--port 7770] [--parallel 64] [--container arbench]
|
|||||||
import argparse
|
import argparse
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
|
||||||
|
if _HERE not in sys.path:
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
|
|
||||||
|
import qwen38_common as qc # noqa: E402 - sibling module, same directory
|
||||||
|
|
||||||
DONE_MARK = "calibration wrote"
|
DONE_MARK = "calibration wrote"
|
||||||
|
|
||||||
|
|
||||||
@@ -49,9 +67,9 @@ def tap_done(container):
|
|||||||
return DONE_MARK in (out.stdout + out.stderr)
|
return DONE_MARK in (out.stdout + out.stderr)
|
||||||
|
|
||||||
|
|
||||||
def push_one(url, prompt):
|
def push_one(url, prompt, model):
|
||||||
body = json.dumps({
|
body = json.dumps({
|
||||||
"model": "frank-archon",
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"max_tokens": 1,
|
"max_tokens": 1,
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
@@ -68,38 +86,151 @@ def push_one(url, prompt):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def push_round(url, prompts, model, parallel):
|
||||||
|
"""Push the whole corpus once; returns how many requests were accepted."""
|
||||||
|
ok = 0
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(parallel) as ex:
|
||||||
|
for good in ex.map(lambda p: push_one(url, p, model), prompts):
|
||||||
|
ok += 1 if good else 0
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def tap_bytes(path):
|
||||||
|
"""Bytes the tap has sealed so far; 0 when the directory is absent.
|
||||||
|
|
||||||
|
A tap whose directory is wrong, full or read-only stops writing while the
|
||||||
|
engine keeps answering 200 — the driver then re-pushes the whole corpus for
|
||||||
|
every round it is allowed and the loss is invisible until the hours are gone.
|
||||||
|
"""
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
for root, _, files in os.walk(path):
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
total += os.path.getsize(os.path.join(root, f))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def round_verdict(before, after, done):
|
||||||
|
"""What one round's tap bytes mean.
|
||||||
|
|
||||||
|
'done' the engine logged that it sealed the tap — the run is over
|
||||||
|
'shrink' the tap LOST bytes: reset, pruned or replaced under us
|
||||||
|
'grew' sealed bytes this round
|
||||||
|
'flat' sealed nothing — which is legitimate: RADIANCE_CALIB_TOKENS is a
|
||||||
|
pooled floor, and a round that does not reach it has nothing to
|
||||||
|
write yet (the campaign's corpus is 10.03M tokens against a 12M
|
||||||
|
floor, so round 1 is always flat). Failing on it aborted the run
|
||||||
|
exactly one round too early.
|
||||||
|
"""
|
||||||
|
if done:
|
||||||
|
return "done"
|
||||||
|
if after < before:
|
||||||
|
return "shrink"
|
||||||
|
if after == before:
|
||||||
|
return "flat"
|
||||||
|
return "grew"
|
||||||
|
|
||||||
|
|
||||||
|
def require_rig(a):
|
||||||
|
"""Refuse unless the rig that opened this tap is up and serving right now.
|
||||||
|
|
||||||
|
Three facts, in order: the runner wrote its marker into the tap dir, the
|
||||||
|
marker names the container this pusher targets, and that container is
|
||||||
|
running. A marker without a live container is a boot that died; a live
|
||||||
|
container without a marker is some other engine on the port — on 2026-10-29
|
||||||
|
it was restored production, which took four rounds of 10M tokens before the
|
||||||
|
old driver admitted it had recorded nothing.
|
||||||
|
"""
|
||||||
|
marker = os.path.join(a.tap_dir, a.tap_marker)
|
||||||
|
if not os.path.isfile(marker):
|
||||||
|
qc.refuse(f"no rig marker at {marker}: this tap was not opened by "
|
||||||
|
"run-qwen38-bf16.sh calib, so a push here would feed whatever "
|
||||||
|
"answers the port and record nothing")
|
||||||
|
try:
|
||||||
|
lines = open(marker, encoding="utf-8", errors="replace").read().splitlines()
|
||||||
|
except OSError as exc:
|
||||||
|
qc.refuse(f"cannot read the rig marker {marker}: {exc}")
|
||||||
|
meta = dict(line.split("=", 1) for line in lines if "=" in line)
|
||||||
|
who = meta.get("container", "")
|
||||||
|
if who != a.container:
|
||||||
|
qc.refuse(f"the rig marker names container {who!r} and this pusher targets "
|
||||||
|
f"{a.container!r} — wrong tap, or a stale marker")
|
||||||
|
try:
|
||||||
|
p = subprocess.run(["podman", "inspect", "-f", "{{.State.Running}}", a.container],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
except Exception as exc:
|
||||||
|
qc.refuse(f"cannot inspect the rig container {a.container}: {exc}")
|
||||||
|
if p.returncode != 0 or p.stdout.strip() != "true":
|
||||||
|
qc.refuse(f"the rig container {a.container} is not running — whatever is "
|
||||||
|
"answering the port, it is not the calibration rig")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--port", type=int, default=7770)
|
ap.add_argument("--port", type=int, default=7770)
|
||||||
ap.add_argument("--parallel", type=int, default=64)
|
ap.add_argument("--parallel", type=int, default=64)
|
||||||
ap.add_argument("--container", default="arbench")
|
ap.add_argument("--container", default="arbench")
|
||||||
|
ap.add_argument("--model", default="frank-archon")
|
||||||
|
ap.add_argument("--tap-dir", default="/home/dank/campaign/qwen38-w5nl64/calib")
|
||||||
|
ap.add_argument("--tap-marker", default=".rig-boot",
|
||||||
|
help="the file run-qwen38-bf16.sh calib writes into the tap dir; without it the pusher refuses, because an unmarked tap is not a rig")
|
||||||
ap.add_argument("--corpus", default="/home/dank/corpora/out/calib.jsonl")
|
ap.add_argument("--corpus", default="/home/dank/corpora/out/calib.jsonl")
|
||||||
ap.add_argument("--max-rounds", type=int, default=4)
|
ap.add_argument("--max-rounds", type=int, default=4)
|
||||||
a = ap.parse_args()
|
return ap.parse_args()
|
||||||
url = f"http://127.0.0.1:{a.port}/v1/completions"
|
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompts(a) -> list[str]:
|
||||||
|
if not os.path.isfile(a.corpus):
|
||||||
|
qc.refuse(f"no corpus at {a.corpus}")
|
||||||
prompts = []
|
prompts = []
|
||||||
with open(a.corpus) as f:
|
for lineno, rec in qc.iter_jsonl(a.corpus):
|
||||||
for line in f:
|
if not isinstance(rec.get("prompt"), str):
|
||||||
line = line.strip()
|
qc.refuse(f"{a.corpus}:{lineno} has no string 'prompt' field")
|
||||||
if line:
|
prompts.append(rec["prompt"])
|
||||||
prompts.append(json.loads(line)["prompt"])
|
if not prompts:
|
||||||
print(f"corpus: {len(prompts)} prompts", flush=True)
|
qc.refuse(f"the corpus at {a.corpus} holds no prompts")
|
||||||
|
return prompts
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = parse_args()
|
||||||
|
url = f"http://127.0.0.1:{a.port}/v1/completions"
|
||||||
|
prompts = load_prompts(a)
|
||||||
|
require_rig(a)
|
||||||
|
chars = sum(len(p) for p in prompts)
|
||||||
|
print(f"corpus: {len(prompts)} prompts, ~{chars // 4} tokens (chars/4)",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
for rnd in range(1, a.max_rounds + 1):
|
for rnd in range(1, a.max_rounds + 1):
|
||||||
if tap_done(a.container):
|
if tap_done(a.container):
|
||||||
break
|
break
|
||||||
|
before = tap_bytes(a.tap_dir)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
ok = 0
|
ok = push_round(url, prompts, a.model, a.parallel)
|
||||||
with concurrent.futures.ThreadPoolExecutor(a.parallel) as ex:
|
dt = max(time.time() - t0, 1e-9)
|
||||||
for good in ex.map(lambda p: push_one(url, p), prompts):
|
|
||||||
ok += 1 if good else 0
|
|
||||||
dt = time.time() - t0
|
|
||||||
print(f"round {rnd}: {ok}/{len(prompts)} ok in {dt:.0f}s "
|
print(f"round {rnd}: {ok}/{len(prompts)} ok in {dt:.0f}s "
|
||||||
f"({sum(len(p) for p in prompts)/4/dt:.0f} chars/s est)",
|
f"({chars / 4 / dt:.0f} tokens/s est)", flush=True)
|
||||||
flush=True)
|
if ok == 0:
|
||||||
|
print("FAIL: not one prompt was accepted; fix the engine or the argv "
|
||||||
|
"before spending another round", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
if tap_done(a.container):
|
if tap_done(a.container):
|
||||||
break
|
break
|
||||||
|
after = tap_bytes(a.tap_dir)
|
||||||
|
verdict = round_verdict(before, after, False)
|
||||||
|
if verdict == "shrink":
|
||||||
|
print(f"FAIL: the tap at {a.tap_dir} LOST bytes this round "
|
||||||
|
f"({before} B before, {after} B after) — it was reset or replaced "
|
||||||
|
"under us", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
if verdict == "flat":
|
||||||
|
print(f"round {rnd}: the tap sealed nothing yet (byte floor not reached; "
|
||||||
|
f"{before} B) — continuing", flush=True)
|
||||||
else:
|
else:
|
||||||
print("max rounds exhausted, tap not done", flush=True)
|
print("max rounds exhausted, tap not done", flush=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
# deployed knob has since dropped to 262144.
|
# deployed knob has since dropped to 262144.
|
||||||
set -euo pipefail
|
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
|
CUR=/home/dank/models/halogen-flashnext-v2
|
||||||
NEW=/home/dank/models/halogen-v2-abliterated
|
NEW=/home/dank/models/halogen-v2-abliterated
|
||||||
CUR_CK=qwen38-flash-next-v2-swift15-abliterated.hgn
|
CUR_CK=qwen38-flash-next-v2-swift15-abliterated.hgn
|
||||||
|
|||||||
@@ -47,7 +47,12 @@
|
|||||||
set -u -o pipefail
|
set -u -o pipefail
|
||||||
|
|
||||||
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
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}
|
CKPT=${CKPT:-/home/dank/models/Qwen3.8-Flash-Next}
|
||||||
CAMPAIGN=${CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}
|
CAMPAIGN=${CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}
|
||||||
RECIPE=${RECIPE:-$SELF/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe}
|
RECIPE=${RECIPE:-$SELF/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe}
|
||||||
@@ -71,10 +76,12 @@ PLAN_ONLY=0
|
|||||||
KEEP_RECIPE=0
|
KEEP_RECIPE=0
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--arm) ARMS+=("$2"); shift 2 ;;
|
--arm) [ "$#" -ge 2 ] || { echo "--arm needs a value (cd3|cd6|ao1)" >&2; exit 2; }
|
||||||
|
ARMS+=("$2"); shift 2 ;;
|
||||||
--dry-run) DRY=1; shift ;;
|
--dry-run) DRY=1; shift ;;
|
||||||
--plan-only) PLAN_ONLY=1; shift ;;
|
--plan-only) PLAN_ONLY=1; shift ;;
|
||||||
--target-count) TARGET_COUNT="$2"; shift 2 ;;
|
--target-count) [ "$#" -ge 2 ] || { echo "--target-count needs a value" >&2; exit 2; }
|
||||||
|
TARGET_COUNT="$2"; shift 2 ;;
|
||||||
--keep-recipe) KEEP_RECIPE=1; shift ;;
|
--keep-recipe) KEEP_RECIPE=1; shift ;;
|
||||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@@ -124,6 +131,13 @@ fi
|
|||||||
for a in "${ARMS[@]}"; do
|
for a in "${ARMS[@]}"; do
|
||||||
[ -e "$OUTDIR/qwen38-w5nl64-$a.rad" ] && {
|
[ -e "$OUTDIR/qwen38-w5nl64-$a.rad" ] && {
|
||||||
echo "$OUTDIR/qwen38-w5nl64-$a.rad already exists; move it to trash first" >&2; exit 2; }
|
echo "$OUTDIR/qwen38-w5nl64-$a.rad already exists; move it to trash first" >&2; exit 2; }
|
||||||
|
# A killed run leaves its container behind (--rm only fires when podman itself
|
||||||
|
# exits), and the next arm then dies on "name already in use" AFTER the plan
|
||||||
|
# phase; refuse here, where the fix is one `podman rm -f`.
|
||||||
|
podman container exists "qwen38-convert-$a" && {
|
||||||
|
echo "container qwen38-convert-$a still exists (a killed run?);" \
|
||||||
|
"'podman rm -f qwen38-convert-$a' and re-run" >&2
|
||||||
|
exit 2; }
|
||||||
done
|
done
|
||||||
|
|
||||||
# --- the protected plane, spliced into a per-run copy of the template --------------------------
|
# --- the protected plane, spliced into a per-run copy of the template --------------------------
|
||||||
@@ -203,6 +217,7 @@ for arm in "${ARMS[@]}"; do
|
|||||||
rc=0; run_convert "$arm" || rc=$?
|
rc=0; run_convert "$arm" || rc=$?
|
||||||
[ "$rc" -eq 0 ] || { echo "arm $arm: rad-convert rc=$rc; log: $STATE/convert-$arm-$TS.log" >&2; overall=1; continue; }
|
[ "$rc" -eq 0 ] || { echo "arm $arm: rad-convert rc=$rc; log: $STATE/convert-$arm-$TS.log" >&2; overall=1; continue; }
|
||||||
out="$OUTDIR/qwen38-w5nl64-$arm.rad"
|
out="$OUTDIR/qwen38-w5nl64-$arm.rad"
|
||||||
|
[ -f "$out" ] || { echo "arm $arm: rad-convert exited 0 but wrote no $out; log: $STATE/convert-$arm-$TS.log" >&2; overall=1; continue; }
|
||||||
got_mib=$(( $(stat -c %s "$out") / 1048576 ))
|
got_mib=$(( $(stat -c %s "$out") / 1048576 ))
|
||||||
# The receipt: the artifact is what the plan predicted, within the metadata the plan does not
|
# The receipt: the artifact is what the plan predicted, within the metadata the plan does not
|
||||||
# count. An arm that came out materially smaller lost a plane somewhere.
|
# count. An arm that came out materially smaller lost a plane somewhere.
|
||||||
|
|||||||
+19
-1
@@ -60,6 +60,7 @@ SERVICE_ORDER = (
|
|||||||
"calibre-server",
|
"calibre-server",
|
||||||
"plex",
|
"plex",
|
||||||
"tautulli",
|
"tautulli",
|
||||||
|
"audiobookshelf",
|
||||||
"prowlarr",
|
"prowlarr",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
"radarr",
|
"radarr",
|
||||||
@@ -123,6 +124,7 @@ DANKSERVER_SERVICES = {
|
|||||||
"calibre-server",
|
"calibre-server",
|
||||||
"plex",
|
"plex",
|
||||||
"tautulli",
|
"tautulli",
|
||||||
|
"audiobookshelf",
|
||||||
"prowlarr",
|
"prowlarr",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
"radarr",
|
"radarr",
|
||||||
@@ -155,13 +157,14 @@ DANKCLAW_COMPOSE_SERVICES = set()
|
|||||||
# Quadlet unit now — the last compose services were the hermes trio, whose units
|
# 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
|
# have owned their containers since 2026-09-12 and whose compose blocks were
|
||||||
# dead weight that could only create a duplicate container.
|
# 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 = {
|
DANKSERVER_COMPOSE_SERVICES = {
|
||||||
"mariadb",
|
"mariadb",
|
||||||
"redis",
|
"redis",
|
||||||
"romm",
|
"romm",
|
||||||
"copyparty",
|
"copyparty",
|
||||||
"jellyfin",
|
|
||||||
"calibre-web",
|
"calibre-web",
|
||||||
}
|
}
|
||||||
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
|
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
|
||||||
@@ -419,8 +422,14 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
"copyparty": {"copyparty"},
|
"copyparty": {"copyparty"},
|
||||||
"plex": {"plex"},
|
"plex": {"plex"},
|
||||||
"tautulli": {"tautulli"},
|
"tautulli": {"tautulli"},
|
||||||
|
"audiobookshelf": {"audiobookshelf"},
|
||||||
}
|
}
|
||||||
_add(services, *quadlet_services.get(parts[1], set()))
|
_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":
|
if len(parts) >= 2 and parts[1] == "observability":
|
||||||
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
||||||
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
||||||
@@ -466,6 +475,15 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
# that service (target_services then keeps it only on the host being
|
# that service (target_services then keeps it only on the host being
|
||||||
# planned). Editing the collector restarts the running exporter.
|
# planned). Editing the collector restarts the running exporter.
|
||||||
_add(services, "gpu-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"}:
|
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
|
# 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
|
# files here; each one selects frank-zealot only. The ik_llama.cpp
|
||||||
|
|||||||
+123
-7
@@ -95,6 +95,7 @@ service_is_allowed() {
|
|||||||
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
|
dankserver:audiobookshelf|\
|
||||||
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
||||||
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
|
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
|
||||||
dankserver:frank-archon|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
dankserver:frank-archon|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||||
@@ -322,11 +323,13 @@ copy_alloy_config() {
|
|||||||
# The repo config carries the token GRAFANA_HOST_LABEL; the installed copy
|
# The repo config carries the token GRAFANA_HOST_LABEL; the installed copy
|
||||||
# carries the host's name, so Loki streams say WHICH machine's journal a
|
# carries the host's name, so Loki streams say WHICH machine's journal a
|
||||||
# line came from without a second config file per host.
|
# line came from without a second config file per host.
|
||||||
# *_CONF_ROOT are test seams: the bats cases execute these copiers for
|
# *_CONF_ROOT and ALLOY_CONFIG_SRC are test seams: the bats cases execute
|
||||||
# real against a sandbox with a stubbed run_privileged.
|
# these copiers for real against a sandbox with a stubbed run_privileged;
|
||||||
|
# ALLOY_CONFIG_SRC lets the refusal case feed a deliberately broken config.
|
||||||
local root="${ALLOY_CONF_ROOT:-/opt/alloy}" tmp
|
local root="${ALLOY_CONF_ROOT:-/opt/alloy}" tmp
|
||||||
|
local src="${ALLOY_CONFIG_SRC:-shared/alloy/config.alloy}"
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
sed "s/GRAFANA_HOST_LABEL/$TARGET/g" shared/alloy/config.alloy > "$tmp"
|
sed "s/GRAFANA_HOST_LABEL/$TARGET/g" "$src" > "$tmp"
|
||||||
if ! grep -q "host = \"$TARGET\"," "$tmp" || grep -q "GRAFANA_HOST_LABEL" "$tmp"; then
|
if ! grep -q "host = \"$TARGET\"," "$tmp" || grep -q "GRAFANA_HOST_LABEL" "$tmp"; then
|
||||||
echo "alloy config: host-label substitution failed" >&2
|
echo "alloy config: host-label substitution failed" >&2
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
@@ -1202,7 +1205,80 @@ deploy_romm_stack() {
|
|||||||
systemctl --user restart "$service.service"
|
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.
|
||||||
|
# A fresh host has no upper base, and `install` refuses to invent one — so
|
||||||
|
# without this the media deploy dies on a rebuilt machine with
|
||||||
|
# "put it on the SSD first" (measured 2026-10-31).
|
||||||
|
run_privileged bash scripts/media-view.sh ensure-base
|
||||||
|
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() {
|
deploy_jellyfin() {
|
||||||
|
ensure_media_view
|
||||||
|
ensure_user_io_delegate
|
||||||
|
ensure_provider_keys
|
||||||
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
if ! systemctl --user is-active -q jellyfin.service; then
|
if ! systemctl --user is-active -q jellyfin.service; then
|
||||||
@@ -1227,6 +1303,37 @@ deploy_calibre_web() {
|
|||||||
systemctl --user restart calibre-web.service
|
systemctl --user restart calibre-web.service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Audiobookshelf keeps its whole state (SQLite library, accounts, progress,
|
||||||
|
# cached covers) on the NVMe under /home/dank/Containers/audiobookshelf; the
|
||||||
|
# audio is a read-only bind off the array, so the only thing the deployer has to
|
||||||
|
# create is the two state roots. mkdir names the owner — letting podman invent
|
||||||
|
# the directories leaves the SQLite file unwritable by the container user on a
|
||||||
|
# rootless first boot. No `systemctl --user enable`: a generated Quadlet unit
|
||||||
|
# cannot be enabled, and WantedBy=default.target is what carries it across a
|
||||||
|
# reboot.
|
||||||
|
deploy_audiobookshelf() {
|
||||||
|
cp "dankserver/audiobookshelf/quadlet/audiobookshelf.container" "$quadlet_unit_dir/"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
mkdir -p /home/dank/Containers/audiobookshelf/config \
|
||||||
|
/home/dank/Containers/audiobookshelf/metadata
|
||||||
|
systemctl --user restart audiobookshelf.service
|
||||||
|
# /healthcheck answers once the HTTP server is bound, at the root whatever
|
||||||
|
# ROUTER_BASE_PATH says (measured on both base paths); the library scan that
|
||||||
|
# follows runs in the background and must not gate the deploy.
|
||||||
|
wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90
|
||||||
|
# A server with no root user and no library is "healthy" and useless, so the
|
||||||
|
# bootstrapper runs after the health gate and its failure fails the deploy:
|
||||||
|
# it creates the account (credential in the mode-600 host secret file), the
|
||||||
|
# Audiobooks library over the /audiobooks bind, and fires the scan. Every
|
||||||
|
# step is idempotent, so a re-deploy costs one incremental scan.
|
||||||
|
# The bootstrapper has to speak the same base path the unit mounts (the API, the
|
||||||
|
# login route and the OIDC redirect subfolder all hang off it), so the URL is read
|
||||||
|
# out of the unit file here instead of being restated in the script's default.
|
||||||
|
abs_base=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' \
|
||||||
|
dankserver/audiobookshelf/quadlet/audiobookshelf.container)
|
||||||
|
ABS_URL="http://127.0.0.1:8000${abs_base}" scripts/audiobookshelf-init.sh
|
||||||
|
}
|
||||||
|
|
||||||
# The content server's single admin account and the desktop's basic-auth
|
# The content server's single admin account and the desktop's basic-auth
|
||||||
# credential (same pair) are bootstrapped exactly once: the env file is the
|
# credential (same pair) are bootstrapped exactly once: the env file is the
|
||||||
# plaintext store (mode 600), the userdb's pw column is plaintext too, so
|
# plaintext store (mode 600), the userdb's pw column is plaintext too, so
|
||||||
@@ -1290,6 +1397,8 @@ deploy_copyparty() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deploy_plex() {
|
deploy_plex() {
|
||||||
|
ensure_media_view
|
||||||
|
ensure_user_io_delegate
|
||||||
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
if ! systemctl --user is-active -q plex.service; then
|
if ! systemctl --user is-active -q plex.service; then
|
||||||
@@ -1362,11 +1471,17 @@ deploy_authentik() {
|
|||||||
# generated". Boot persistence comes from [Install] WantedBy=default.target
|
# generated". Boot persistence comes from [Install] WantedBy=default.target
|
||||||
# inside each .container file, which the generator honors at user-manager
|
# inside each .container file, which the generator honors at user-manager
|
||||||
# start (linger must stay on for the deploy user).
|
# 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
|
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
|
wait_container_healthy authentik-server 300 || return 1
|
||||||
systemctl --user start authentik-worker.service
|
systemctl --user restart authentik-worker.service
|
||||||
}
|
}
|
||||||
|
|
||||||
deploy_authentik_outpost() {
|
deploy_authentik_outpost() {
|
||||||
@@ -1482,7 +1597,7 @@ deploy_anubis() {
|
|||||||
# artifact would leave a restart-looping unit and a dark endpoint. The
|
# 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
|
# port gate is the same as every cutover since 2026-09-16: nothing
|
||||||
# starts until 7770 is released.
|
# 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
|
# 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.
|
# 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}"
|
LLM_DRAGOON_MODEL_DIR="${LLM_DRAGOON_MODEL_DIR:-/home/dank/models/halogen-v2-abliterated}"
|
||||||
@@ -2095,6 +2210,7 @@ deploy_service() {
|
|||||||
copyparty) deploy_copyparty ;;
|
copyparty) deploy_copyparty ;;
|
||||||
plex) deploy_plex ;;
|
plex) deploy_plex ;;
|
||||||
tautulli) deploy_tautulli ;;
|
tautulli) deploy_tautulli ;;
|
||||||
|
audiobookshelf) deploy_audiobookshelf ;;
|
||||||
alloy) deploy_alloy ;;
|
alloy) deploy_alloy ;;
|
||||||
prometheus|grafana|loki|node-exporter|cadvisor|alertmanager|blackbox-exporter) deploy_observability "$service" ;;
|
prometheus|grafana|loki|node-exporter|cadvisor|alertmanager|blackbox-exporter) deploy_observability "$service" ;;
|
||||||
gpu-exporter) deploy_gpu_exporter ;;
|
gpu-exporter) deploy_gpu_exporter ;;
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ valid_service() {
|
|||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
|
dankserver:audiobookshelf|\
|
||||||
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
||||||
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||||
dankserver:prowlarr|dankserver:frank-archon|\
|
dankserver:prowlarr|dankserver:frank-archon|\
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
# (/opt/halogen/npu/models.txt inside the image) and refuses to load a model
|
# (/opt/halogen/npu/models.txt inside the image) and refuses to load a model
|
||||||
# whose files do not match.
|
# whose files do not match.
|
||||||
#
|
#
|
||||||
# The manifest below IS that catalog block (dumped from the pinned 0.16.2
|
# The manifest below IS that catalog block (dumped from the pinned 0.18.20
|
||||||
# image on 2026-10-19), so the files land in the layout the entrypoint
|
# 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
|
# expects — <dir>/<model-id>/devices/*, <dir>/<model-id>/<model-id>.hnpw and
|
||||||
# <dir>/<model-id>/tokenizer/tokenizer.json — and the endpoint can keep
|
# <dir>/<model-id>/tokenizer/tokenizer.json — and the endpoint can keep
|
||||||
# /models read-only and open no outbound connection at boot (HALOGEN_DOWNLOAD
|
# /models read-only and open no outbound connection at boot (HALOGEN_DOWNLOAD
|
||||||
@@ -57,21 +59,25 @@ manifest() {
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
qwen3-embedding-0.6b)
|
qwen3-embedding-0.6b)
|
||||||
NPU_REPO="peonist-ai/halogen-npu-qwen3-embedding-0.6b"
|
NPU_REPO="peonist-ai/halogen-npu-qwen3-embedding-0.6b"
|
||||||
NPU_REV="4960fd9d4423dd15373ad21c83db48dbdb3b2798"
|
NPU_REV="8fd44c9f18861618a509bea75c51ff8a37e3bd5d"
|
||||||
FILES=(
|
FILES=(
|
||||||
"devices/NOTICE.md 3248 0f267b3d2523ddf14997ee58660a492a9eaf10ccf6b08bf19dfdc85b9a478b91"
|
"devices/NOTICE.md 3248 0f267b3d2523ddf14997ee58660a492a9eaf10ccf6b08bf19dfdc85b9a478b91"
|
||||||
"devices/consts.hnpb 5086496 ae430e41545a0b1012938cda373b0c450364d3be631111cca3a8c43679f93336"
|
"devices/consts.hnpb 8495512 5af7c1f2f24d45a9054872bba882461cf7f6ffda832e785b700643d6467ad88b"
|
||||||
"devices/devices.hnpm 24047 4e9bca9d95d084fb89ce5b99f579a00666c8ca1efae2613c53e3a4878cc9f174"
|
"devices/devices.hnpm 35465 85d870f6b900de1829659955f0dc701d57984580f5e5ec251add5d6969f03e3d"
|
||||||
"devices/u0.elf 4931968 b38f165886cb037b7d3951d9cb878473eaba58a432acd45416ff85fd88aa4e46"
|
"devices/u0.elf 2975008 f74df12eb8c79e4e90cbedcc1dd8f142219772d400e3a142fde8a95378b105eb"
|
||||||
"devices/u1.elf 5456000 b1811feee851b8af833b37721da92099d89772110815bbbe2c3fa1c276f0ff42"
|
"devices/u1.elf 3266848 ac5a3f89f934951b315b62ab53e1d0a74dbe6396134192641c405d08da332754"
|
||||||
"devices/u2.elf 6505552 406ea9a95b470628a7382e376f4a72cdf4d7aa4c59b4ddf0a4bdd139d63d6ff2"
|
"devices/u2.elf 4015328 5e4f0d3342f262816289866d7e8c966f56a9f938c2e12e7341718cc7312aa480"
|
||||||
"devices/u3.elf 6817216 bc205d51b523d770151a0c61968f7c70bf624412827580bb6fe0aaf4650dc2ee"
|
"devices/u3.elf 4326976 94cf5d269fa286d91c26bf4654cedf101c047d23c4495b74c9c129cdc33b210f"
|
||||||
"devices/u4.elf 7444032 a5a91338a2603acdcd5c11df532033fda2d3f5479ea44ae87f98fafbdff9a62d"
|
"devices/u4.elf 4953792 cbd2796050d4c675f5cb3da117111087d510173e26f262f1b36aab66dd49dc91"
|
||||||
"devices/u5.elf 7432416 6c2bf41835569e0eba25ded7c05c7264ae69c6700bdeb4791057d884f2a4c126"
|
"devices/u5.elf 4940112 7bf6a4470546bb888da02af395c07d92a24fb6d43aef27eb2151b3a9eda19e4d"
|
||||||
"devices/u6.elf 4941232 919d0038ca088eafddf195f3466ff38db27d3635be1cffa5d4d4b5140902a68e"
|
"devices/u6.elf 6174544 10b9d50b635993e1f9c23775c0b94901d922c1e433cd10910e9f97174235f5ac"
|
||||||
"devices/u7.elf 5488304 d904cf8afae9428dd167960549e18cce5441745f18c62f8c50975dffc077d3c2"
|
"devices/u7.elf 2983776 e0b06063b7379d05c85b3f435b819d96fda1223d8025d6d15958854b5711ace9"
|
||||||
"devices/u8.elf 4941232 db3aef69b17825854ee0dd2df475125a3f5c872f95c686793d6af1ee83596fa7"
|
"devices/u8.elf 3298656 3bad204f782a68b97e27c1172530b13d7fbb0b495fa95f9504530a1048560644"
|
||||||
"devices/u9.elf 5488304 d182727e551084db8fb9600441d8aa373c7f09a3d267ae0a5e921ef92326db1c"
|
"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"
|
"qwen3-embedding-0.6b.hnpw 813032248 b9f9901429df7b46ec17153aa3899f738adbea8e4356080da0c6e6586fb750d1"
|
||||||
"tokenizer/tokenizer.json 11423705 def76fb086971c7867b829c23a26261e38d9d74e02139253b38aeb9df8b4b50a"
|
"tokenizer/tokenizer.json 11423705 def76fb086971c7867b829c23a26261e38d9d74e02139253b38aeb9df8b4b50a"
|
||||||
) ;;
|
) ;;
|
||||||
@@ -99,7 +105,18 @@ for MODEL in ${MODELS//,/ }; do
|
|||||||
# 62 GiB fetch hit this on 2026-10-18).
|
# 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") \
|
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; }
|
|| { 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
|
||||||
fi
|
fi
|
||||||
[ -f "$path" ] || { echo "MISSING $path (run without --verify-only)" >&2; exit 1; }
|
[ -f "$path" ] || { echo "MISSING $path (run without --verify-only)" >&2; exit 1; }
|
||||||
|
|||||||
+12
-10
@@ -74,12 +74,14 @@ FRANK_ARCHON_PLACEMENT=expert_tiered
|
|||||||
# residency -- ~3.2 GiB a card of experts now lives in host RAM and is read over the
|
# residency -- ~3.2 GiB a card of experts now lives in host RAM and is read over the
|
||||||
# x8 link when the router picks it. The ledger's coverage line is the referee, not
|
# x8 link when the router picks it. The ledger's coverage line is the referee, not
|
||||||
# this file: if vram_kv comes back under 700,000 tokens, raise this 1024 and redeploy.
|
# this file: if vram_kv comes back under 700,000 tokens, raise this 1024 and redeploy.
|
||||||
# 2026-10-29: 49,152 -> 66,560 (tp x 65 GiB, inside the 246 GiB the box has). The
|
# 2026-10-29 A/B, measured on both payloads and folded back the same day: at 66,560 the
|
||||||
# campaign rig's bf16 container at the shared step reported "kv cache 269.99 MiB a
|
# endpoint printed the identical `elastic 26.73 GiB / experts 20.72 / kv cache 5.64 /
|
||||||
# card: 7436 tokens", so 682 of the 1,294 calibration documents were refused at that
|
# vram_weights 24.30` it had printed at 49,152, opening the bigger pool with `0 B used`;
|
||||||
# pool; the pool, not the step, is what widens it (scripts/run-qwen38-bf16.sh). The
|
# the campaign rig (bf16, same step) moved `vram_kv 269.99 -> 271.25 MiB`, a rounding
|
||||||
# new cache line belongs to the next boot ledger, not to this comment.
|
# step — its window is fixed by its own n-gram table (95.37 GiB) and expert plane, not
|
||||||
FRANK_ARCHON_HOST_POOL_MIB=66560
|
# by this pool, so the refused calibration documents were fixed by cutting them to the
|
||||||
|
# window (scripts/rechunk-kld-corpus.py), not by widening the pool.
|
||||||
|
FRANK_ARCHON_HOST_POOL_MIB=49152
|
||||||
# VRAM left unclaimed per card (publisher default 96 MiB); the engine's settle
|
# VRAM left unclaimed per card (publisher default 96 MiB); the engine's settle
|
||||||
# floor is built from it.
|
# floor is built from it.
|
||||||
FRANK_ARCHON_GPU_HEADROOM_MIB=96
|
FRANK_ARCHON_GPU_HEADROOM_MIB=96
|
||||||
@@ -142,10 +144,10 @@ FRANK_ARCHON_NGRAM_PLACEMENT=ram
|
|||||||
# itself before refusing by name, so recovery is a boot, not a visit.
|
# itself before refusing by name, so recovery is a boot, not a visit.
|
||||||
# Floor = 2 x HOST_POOL + 48,824 (static weights + arena) + PREFIX_CACHE + MNBT + the
|
# Floor = 2 x HOST_POOL + 48,824 (static weights + arena) + PREFIX_CACHE + MNBT + the
|
||||||
# auto checkpoint store (MAX_SEQS x 10 slots of 111.98 MiB), per the bats ledger test;
|
# auto checkpoint store (MAX_SEQS x 10 slots of 111.98 MiB), per the bats ledger test;
|
||||||
# at the 66,560 pool with 4 slots that computes to 196,664 MiB (161,848 at the old
|
# at the 49,152 pool with 4 slots that computes to 161,848 MiB (196,664 at the
|
||||||
# 49,152 pool).
|
# 66,560 pool the 2026-10-29 A/B rejected).
|
||||||
FRANK_ARCHON_TTM_MIN_MIB=197000
|
FRANK_ARCHON_TTM_MIN_MIB=162000
|
||||||
FRANK_ARCHON_TTM_TARGET_MIB=224000
|
FRANK_ARCHON_TTM_TARGET_MIB=204800
|
||||||
# Escape hatch for experiments. Unset -> no extra argv; the serve script expands
|
# Escape hatch for experiments. Unset -> no extra argv; the serve script expands
|
||||||
# this unquoted on purpose, so keep multi-flag values as one string.
|
# this unquoted on purpose, so keep multi-flag values as one string.
|
||||||
FRANK_ARCHON_EXTRA_ARGS=
|
FRANK_ARCHON_EXTRA_ARGS=
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
# The .hgn format is the lock: it loads ONLY in the flash-server line, so the
|
# 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.
|
# engine moves with the payload, never ahead of it.
|
||||||
# Image digest-pinned (a tag pull silently re-points; the digest is what was
|
# 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).
|
# qualified). 0.18.20 since 2026-10-30 (was 0.16.2, the model card's quickstart
|
||||||
FRANK_DRAGOON_IMAGE=ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a
|
# 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.
|
# Container identity kept from the 27B era: the unit's ExecStop names it.
|
||||||
FRANK_DRAGOON_CONTAINER=frank-dragoon-halogen
|
FRANK_DRAGOON_CONTAINER=frank-dragoon-halogen
|
||||||
# Predecessor identity the serve script clears if it is still holding the GPUs.
|
# 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,
|
# frank-zealot — llama.cpp (PrismML fork build 12118 = 0.7.0-dev, commit
|
||||||
# :7771. Payload: Ternary-Bonsai-2-9B PQ2_0 with the MTP head in the file, plus
|
# 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.
|
# the refusal-direction adapter applied at load.
|
||||||
#
|
#
|
||||||
# ONE OWNER: shape, payload identity, eras, laws and every measured number
|
# ONE OWNER: shape, payload identity, eras, laws and every measured number
|
||||||
# live in serverAppConfigs-docs/LLM/frank-zealot.md. This file carries the
|
# live in serverAppConfigs-docs/LLM/frank-zealot.md. This file carries the
|
||||||
# knobs and only the reason a reader editing THIS line cannot recover
|
# knobs and only the reason a reader editing THIS line cannot recover
|
||||||
# elsewhere. Where the two disagree, this file wins on values.
|
# 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.*
|
# 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. Stock llama.cpp refuses the file; a loader ignoring the Hadamard
|
||||||
# metadata dequantizes wrong, silently.
|
# 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
|
# 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
|
# 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
|
# 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
|
# SPEC_TYPE=none is the rollback and strips the WHOLE set (--spec-type and
|
||||||
# every --spec-draft-*); half a set has segfaulted at load.
|
# every --spec-draft-*); half a set has segfaulted at load.
|
||||||
SPEC_TYPE=draft-mtp
|
SPEC_TYPE=draft-mtp
|
||||||
|
|||||||
Executable
+462
@@ -0,0 +1,462 @@
|
|||||||
|
#!/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 in place, and that no library
|
||||||
|
# is a silent file index (providers off / image fetchers empty)
|
||||||
|
#
|
||||||
|
# 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:
|
||||||
|
# The plugin owns this file and writes it under ITS OWN root element
|
||||||
|
# (TheTVDB's is <PluginConfiguration>), and its serializer omits a
|
||||||
|
# null-valued setting entirely — so the element being filled can be
|
||||||
|
# absent outright, and a hardcoded </Configuration> anchor then matches
|
||||||
|
# nothing. Anchor on the document's real root instead.
|
||||||
|
out = src
|
||||||
|
root = re.search(r"<([A-Za-z_][\w.:-]*)[^>]*>", src)
|
||||||
|
if root and ("</%s>" % root.group(1)) in src:
|
||||||
|
name = root.group(1)
|
||||||
|
out = src.replace(
|
||||||
|
"</%s>" % name, f" <{elem}>{text}</{elem}>\n</{name}>", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# A library whose options.xml carries EnableInternetProviders=false, or an empty
|
||||||
|
# ImageFetchers list beside a populated MetadataFetchers list, is a SILENT FILE
|
||||||
|
# INDEX: it indexes every file, answers 204 to every refresh, stores no provider
|
||||||
|
# id, plot, rating or artwork, and logs nothing — so the item count reads like a
|
||||||
|
# healthy scan. Jellyfin 12 keeps these options in root/default/<Library>/options.xml
|
||||||
|
# (it was library.xml) and stores NONE of them in the database, so this audit is
|
||||||
|
# the only place the state can be read without a browser session.
|
||||||
|
verify_library_options() {
|
||||||
|
local root="$JF_ROOT/root/default" f lib problems
|
||||||
|
if [ ! -d "$root" ]; then
|
||||||
|
printf '= no library options under %s (nothing audited)\n' "$root"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local rc=0
|
||||||
|
for f in "$root"/*/options.xml; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
lib="$(basename "$(dirname "$f")")"
|
||||||
|
problems="$(python3 -s - "$f" <<'PY'
|
||||||
|
import sys, xml.dom.minidom as X
|
||||||
|
root = X.parse(sys.argv[1]).documentElement
|
||||||
|
def child(el, name):
|
||||||
|
for n in el.childNodes:
|
||||||
|
if n.nodeType == n.ELEMENT_NODE and n.tagName == name:
|
||||||
|
return n
|
||||||
|
def flag(el, name):
|
||||||
|
n = child(el, name)
|
||||||
|
return None if n is None else (n.firstChild.data.strip() if n.firstChild else "")
|
||||||
|
def names(el, name):
|
||||||
|
n = child(el, name)
|
||||||
|
return None if n is None else [s.firstChild.data.strip() for s in n.childNodes
|
||||||
|
if s.nodeType == s.ELEMENT_NODE and s.firstChild]
|
||||||
|
bad = []
|
||||||
|
ip = flag(root, "EnableInternetProviders")
|
||||||
|
if ip is not None and ip.lower() != "true":
|
||||||
|
bad.append("EnableInternetProviders=%s" % (ip or "empty"))
|
||||||
|
types = child(root, "TypeOptions")
|
||||||
|
if types is not None:
|
||||||
|
for t in types.childNodes:
|
||||||
|
if t.nodeType != t.ELEMENT_NODE or t.tagName != "TypeOptions":
|
||||||
|
continue
|
||||||
|
meta = names(t, "MetadataFetchers") or []
|
||||||
|
if meta and not (names(t, "ImageFetchers") or []):
|
||||||
|
bad.append("%s: ImageFetchers empty while MetadataFetchers names %d"
|
||||||
|
% (flag(t, "Type") or "?", len(meta)))
|
||||||
|
print("; ".join(bad))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [ -n "$problems" ]; then
|
||||||
|
printf 'SILENT INDEX %s: %s\n' "$lib" "$problems" >&2
|
||||||
|
rc=1
|
||||||
|
else
|
||||||
|
printf '= %s: library options fetch from providers\n' "$lib"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
verify_library_options || bad=1
|
||||||
|
[ "$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
|
||||||
+114
-50
@@ -1,44 +1,53 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# kld-gate-qwen38.sh — RUNS ON DANKSERVER (or anywhere the reports are). The campaign's
|
# kld-gate-qwen38.sh — RUNS ON DANKSERVER (or anywhere the reports are). The campaign's
|
||||||
# acceptance gate: three KLD reports against ONE reference recording, one verdict.
|
# acceptance gate: one verdict from the KLD reports of the containers that ride one reference
|
||||||
|
# recording.
|
||||||
#
|
#
|
||||||
# The reference is the BF16 container built from the vendor checkpoint (run-qwen38-bf16.sh
|
# The reference is the BF16 container built from the vendor checkpoint (run-qwen38-bf16.sh
|
||||||
# kldref, in the same drained window that records the calibration grams). Every scored
|
# kldref, in the same drained window that records the calibration grams). Every scored
|
||||||
# container is measured against that one recording, so the KL scale runs from 0 = the
|
# container is measured against that one recording, so the KL scale runs from 0 = the
|
||||||
# checkpoint itself downward in faithfulness. Three containers ride it:
|
# checkpoint itself downward in faithfulness.
|
||||||
#
|
#
|
||||||
# fp8.json the published FP8-native release — the achievable end of the interval
|
# TWO MODES, because the disk decides how many anchors can exist at once:
|
||||||
# iq4r.json the payload frank-archon serves — the bar to beat
|
|
||||||
# cand.json the arm under test — the subject
|
|
||||||
#
|
#
|
||||||
# The FP8 number is what makes the interval absolute instead of relative: "half the gap"
|
# gate IQ4R.json CAND.json candidate vs the served payload
|
||||||
# is half of the distance between the 4-bit payload and the near-lossless release, not half
|
# gate FP8.json IQ4R.json CAND.json the same, inside the absolute interval
|
||||||
# of an invented distance to the checkpoint. That is also why the FP8 payload is FETCHED and
|
|
||||||
# scored, never converted: it is a second opinion about what this checkpoint tolerates, not a
|
|
||||||
# step of the campaign. It lands LAST (disk sequencing) — by then the 335 GiB bf16 container
|
|
||||||
# is gone, so its 172.8 GiB fits.
|
|
||||||
#
|
#
|
||||||
# The law, in order of severity, on the mean KL of one slice (--slice all | a corpus source):
|
# iq4r.json is the payload frank-archon serves — the bar to beat. When the FP8-native release
|
||||||
# 1. KL(fp8) < KL(iq4r) the anchors are ordered. If the near-lossless
|
# (172.8 GiB, fetched last because of the peak-disk law) is on disk, pass its report first:
|
||||||
# release does NOT sit closer to the checkpoint than
|
# "half the gap" is then half of the distance between the 4-bit payload and the near-lossless
|
||||||
# the 4-bit payload, the corpus, the tokenizer or the
|
# release, not half of an invented distance to the checkpoint. Without it the gate still
|
||||||
# boot shape is wrong and no arm number means anything.
|
# answers the campaign's question — does the candidate beat what is served — and says so.
|
||||||
# 2. KL(fp8) < KL(cand) < KL(iq4r) the candidate lies strictly inside the interval —
|
#
|
||||||
# outside it, something is mis-measured, not better
|
# The law, on the summary of one slice (--slice all | a corpus source):
|
||||||
# 3. KL(cand) <= KL(iq4r) - R x gap it closes at least R (--recover, default 0.5) of the
|
#
|
||||||
# gap = KL(iq4r) - KL(fp8): at least half the way from
|
# candidate vs served (2 reports)
|
||||||
# what is served toward the release (agreed 2026-10-27)
|
# 1. mean KL(cand) < mean KL(iq4r) the served payload is beaten on the corpus
|
||||||
# 4. top-1(cand) >= top-1(iq4r) the agreement the harness keys on does not fall
|
# 2. p99.9(cand) <= p99.9(iq4r) and the tail is no worse — a mean can hide a few
|
||||||
|
# positions taking the whole hit
|
||||||
|
# 3. top-1(cand) >= top-1(iq4r) the agreement the harness keys on does not fall
|
||||||
|
# 4. every source slice passes 1-3 a win bought by regressing one distribution
|
||||||
|
# (code, prose, tools) is not a win
|
||||||
|
#
|
||||||
|
# inside the interval (3 reports), laws 1-4 above (1-3 renumbered to the interval and the
|
||||||
|
# recovery fraction, as agreed 2026-10-27) plus:
|
||||||
|
# 5. p99.9(cand) <= p99.9(iq4r) the tail law
|
||||||
|
# 6. every source slice passes the slice law
|
||||||
#
|
#
|
||||||
# Booting the containers is NOT this script's job: `run-qwen38-bf16.sh score RAD OUT.json`
|
# Booting the containers is NOT this script's job: `run-qwen38-bf16.sh score RAD OUT.json`
|
||||||
# writes the reports and owns the single engine argv (same pin, --tp 2, --kv-cache-dtype fp8,
|
# writes the reports and owns the single engine argv (same pin, --tp 2, --kv-cache-dtype fp8,
|
||||||
# same step size, --deterministic, no speculation) that makes the three numbers comparable.
|
# same step size, --deterministic, no speculation) that makes the numbers comparable.
|
||||||
# This script is arithmetic, so it re-runs against old reports and is testable without a GPU.
|
# This script is arithmetic, so it re-runs against old reports and is testable without a GPU.
|
||||||
#
|
#
|
||||||
# Usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]
|
# Usage: kld-gate-qwen38.sh IQ4R.json CAND.json [--slice NAME]
|
||||||
|
# kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
[ "$#" -ge 3 ] || { echo "usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]" >&2; exit 2; }
|
if [ "$#" -lt 2 ]; then
|
||||||
|
echo "usage: kld-gate-qwen38.sh IQ4R.json CAND.json [--slice NAME]" >&2
|
||||||
|
echo " kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
python3 - "$@" <<'PY'
|
python3 - "$@" <<'PY'
|
||||||
import json, sys
|
import json, sys
|
||||||
|
|
||||||
@@ -52,13 +61,15 @@ while i < len(a):
|
|||||||
else:
|
else:
|
||||||
files.append(a[i])
|
files.append(a[i])
|
||||||
i += 1
|
i += 1
|
||||||
if len(files) != 3:
|
if len(files) not in (2, 3):
|
||||||
sys.exit("usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]")
|
sys.exit("usage: kld-gate-qwen38.sh IQ4R.json CAND.json [--slice NAME]\n"
|
||||||
|
" kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]")
|
||||||
o = dict(zip(opts[0::2], opts[1::2]))
|
o = dict(zip(opts[0::2], opts[1::2]))
|
||||||
slice_ = o.get("--slice", "all")
|
slice_ = o.get("--slice", "all")
|
||||||
recover = float(o.get("--recover", 0.5))
|
recover = float(o.get("--recover", 0.5))
|
||||||
if not 0.0 < recover <= 1.0:
|
if not 0.0 < recover <= 1.0:
|
||||||
sys.exit(f"--recover must be in (0,1], got {recover}")
|
sys.exit(f"--recover must be in (0,1], got {recover}")
|
||||||
|
interval = len(files) == 3
|
||||||
|
|
||||||
|
|
||||||
def die(msg):
|
def die(msg):
|
||||||
@@ -66,11 +77,15 @@ def die(msg):
|
|||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
def read(path):
|
def load(path):
|
||||||
try:
|
try:
|
||||||
d = json.load(open(path))
|
return json.load(open(path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
die(f"cannot read the KLD report {path}: {e}")
|
die(f"cannot read the KLD report {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def summary(path, d):
|
||||||
|
"""The slice this gate grades: 'all', or one corpus source."""
|
||||||
if slice_ == "all":
|
if slice_ == "all":
|
||||||
g = d.get("all")
|
g = d.get("all")
|
||||||
else:
|
else:
|
||||||
@@ -80,23 +95,25 @@ def read(path):
|
|||||||
f"{', '.join(sorted(d.get('by_source') or {})) or '(none)'}")
|
f"{', '.join(sorted(d.get('by_source') or {})) or '(none)'}")
|
||||||
if g is None or "kld" not in g or "top1_agreement" not in g:
|
if g is None or "kld" not in g or "top1_agreement" not in g:
|
||||||
die(f"{path} has no {slice_!r} summary — wrong report version?")
|
die(f"{path} has no {slice_!r} summary — wrong report version?")
|
||||||
return d, g
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
docs = {label: load(path) for label, path in
|
||||||
|
zip(("fp8", "iq4r", "cand") if interval else ("iq4r", "cand"), files)}
|
||||||
rows = []
|
rows = []
|
||||||
for label, path in zip(("fp8", "iq4r", "cand"), files):
|
for label, path in zip(("fp8", "iq4r", "cand") if interval else ("iq4r", "cand"), files):
|
||||||
d, g = read(path)
|
g = summary(path, docs[label])
|
||||||
rows.append((label, path, d.get("model", "?"), g["kld"]["mean"], g["kld"]["p99_9"],
|
rows.append((label, path, docs[label].get("model", "?"), g["kld"]["mean"],
|
||||||
g["top1_agreement"], g["positions"]))
|
g["kld"]["p99_9"], g["top1_agreement"], g["positions"]))
|
||||||
|
|
||||||
print(f"reference: BF16 container; slice: {slice_}")
|
print(f"reference: BF16 container; slice: {slice_}; "
|
||||||
|
+ ("inside the FP8 interval" if interval else "candidate vs served (no FP8 anchor)"))
|
||||||
print(f"{'':6} {'model':38} {'KL mean':>10} {'KL p99.9':>10} {'top-1':>8} positions")
|
print(f"{'':6} {'model':38} {'KL mean':>10} {'KL p99.9':>10} {'top-1':>8} positions")
|
||||||
for label, path, model, kl, p999, top1, n in rows:
|
for label, path, model, kl, p999, top1, n in rows:
|
||||||
print(f"{label:6} {str(model)[:38]:38} {kl:10.6f} {p999:10.4f} {100*top1:7.2f}% {n}")
|
print(f"{label:6} {str(model)[:38]:38} {kl:10.6f} {p999:10.4f} {100*top1:7.2f}% {n}")
|
||||||
r = {label: (kl, top1) for label, _p, _m, kl, _q, top1, _n in rows}
|
r = {label: (kl, p999, top1) for label, _p, _m, kl, p999, top1, _n in rows}
|
||||||
kl_fp8, kl_iq4r, kl_cand = r["fp8"][0], r["iq4r"][0], r["cand"][0]
|
kl_cand, p_cand, t_cand = r["cand"]
|
||||||
t_iq4r, t_cand = r["iq4r"][1], r["cand"][1]
|
kl_iq4r, p_iq4r, t_iq4r = r["iq4r"]
|
||||||
gap = kl_iq4r - kl_fp8
|
|
||||||
ok = True
|
ok = True
|
||||||
|
|
||||||
|
|
||||||
@@ -106,17 +123,64 @@ def law(n, text, passed):
|
|||||||
print(f" [{'PASS' if passed else 'FAIL'}] {n}. {text}")
|
print(f" [{'PASS' if passed else 'FAIL'}] {n}. {text}")
|
||||||
|
|
||||||
|
|
||||||
law(1, f"anchors ordered: fp8 {kl_fp8:.6f} < iq4r {kl_iq4r:.6f} (gap {gap:.6f})",
|
def tail_law(n):
|
||||||
gap > 0)
|
law(n, f"candidate KL p99.9 {p_cand:.4f} <= served iq4r {p_iq4r:.4f}",
|
||||||
law(2, f"candidate inside the interval: {kl_fp8:.6f} < {kl_cand:.6f} < {kl_iq4r:.6f}",
|
p_cand <= p_iq4r)
|
||||||
kl_fp8 < kl_cand < kl_iq4r)
|
|
||||||
need = kl_iq4r - recover * gap
|
|
||||||
law(3, f"candidate closes {100*max((kl_iq4r - kl_cand)/gap, 0):.1f}% of the "
|
# --- the slice law: every corpus source must pass the metric laws -------------
|
||||||
f"{gap:.6f} gap (needs {100*recover:.0f}%, i.e. KL <= {need:.6f})"
|
def slice_law(n):
|
||||||
if gap > 0 else "gap is not positive; cannot score recovery",
|
"""A win that regresses one source class is not a win: check them all."""
|
||||||
gap > 0 and kl_cand <= need)
|
mine = docs["cand"].get("by_source") or {}
|
||||||
law(4, f"candidate top-1 {100*t_cand:.2f}% >= served iq4r top-1 {100*t_iq4r:.2f}%",
|
theirs = docs["iq4r"].get("by_source") or {}
|
||||||
t_cand >= t_iq4r)
|
if not mine or not theirs:
|
||||||
|
law(n, "no per-source summaries to check", True)
|
||||||
|
return
|
||||||
|
missing = sorted(set(theirs) - set(mine))
|
||||||
|
extra = sorted(set(mine) - set(theirs))
|
||||||
|
if missing or extra:
|
||||||
|
law(n, "same source slices in both reports"
|
||||||
|
+ (f" (missing from cand: {', '.join(missing)})" if missing else "")
|
||||||
|
+ (f" (only in cand: {', '.join(extra)})" if extra else ""), False)
|
||||||
|
return
|
||||||
|
bad = []
|
||||||
|
for src in sorted(theirs):
|
||||||
|
c, q = mine[src], theirs[src]
|
||||||
|
worse_mean = c["kld"]["mean"] >= q["kld"]["mean"]
|
||||||
|
worse_tail = c["kld"]["p99_9"] > q["kld"]["p99_9"]
|
||||||
|
worse_top1 = c["top1_agreement"] < q["top1_agreement"]
|
||||||
|
if worse_mean or worse_tail or worse_top1:
|
||||||
|
why = ",".join(m for m, w in (("mean", worse_mean), ("p99.9", worse_tail),
|
||||||
|
("top-1", worse_top1)) if w)
|
||||||
|
bad.append(f"{src} ({why})")
|
||||||
|
law(n, "every source slice passes (mean, p99.9, top-1)"
|
||||||
|
+ (f" — regressed: {'; '.join(bad)}" if bad else ""), not bad)
|
||||||
|
|
||||||
|
|
||||||
|
if interval:
|
||||||
|
kl_fp8 = r["fp8"][0]
|
||||||
|
gap = kl_iq4r - kl_fp8
|
||||||
|
law(1, f"anchors ordered: fp8 {kl_fp8:.6f} < iq4r {kl_iq4r:.6f} (gap {gap:.6f})",
|
||||||
|
gap > 0)
|
||||||
|
law(2, f"candidate inside the interval: {kl_fp8:.6f} < {kl_cand:.6f} < {kl_iq4r:.6f}",
|
||||||
|
kl_fp8 < kl_cand < kl_iq4r)
|
||||||
|
need = kl_iq4r - recover * gap
|
||||||
|
law(3, f"candidate closes {100*max((kl_iq4r - kl_cand)/gap, 0):.1f}% of the "
|
||||||
|
f"{gap:.6f} gap (needs {100*recover:.0f}%, i.e. KL <= {need:.6f})"
|
||||||
|
if gap > 0 else "gap is not positive; cannot score recovery",
|
||||||
|
gap > 0 and kl_cand <= need)
|
||||||
|
law(4, f"candidate top-1 {100*t_cand:.2f}% >= served iq4r top-1 {100*t_iq4r:.2f}%",
|
||||||
|
t_cand >= t_iq4r)
|
||||||
|
tail_law(5)
|
||||||
|
slice_law(6)
|
||||||
|
else:
|
||||||
|
law(1, f"candidate beats the served payload: KL {kl_cand:.6f} < iq4r {kl_iq4r:.6f}",
|
||||||
|
kl_cand < kl_iq4r)
|
||||||
|
tail_law(2)
|
||||||
|
law(3, f"candidate top-1 {100*t_cand:.2f}% >= served iq4r top-1 {100*t_iq4r:.2f}%",
|
||||||
|
t_cand >= t_iq4r)
|
||||||
|
slice_law(4)
|
||||||
|
|
||||||
print("KLD GATE " + ("PASS" if ok else "FAIL"))
|
print("KLD GATE " + ("PASS" if ok else "FAIL"))
|
||||||
sys.exit(0 if ok else 1)
|
sys.exit(0 if ok else 1)
|
||||||
PY
|
PY
|
||||||
+175
-112
@@ -34,14 +34,25 @@ import json
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from tokenizers import Tokenizer
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
|
||||||
|
if _HERE not in sys.path:
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
|
|
||||||
|
import qwen38_common as qc # noqa: E402 - sibling module, same directory
|
||||||
|
|
||||||
CODE_EXT = (".sh", ".py", ".jinja", ".recipe", ".env", ".example",
|
CODE_EXT = (".sh", ".py", ".jinja", ".recipe", ".env", ".example",
|
||||||
".yaml", ".yml", ".bats", ".service", ".container", ".toml")
|
".yaml", ".yml", ".bats", ".service", ".container", ".toml")
|
||||||
JSONL_EXTS = (".jsonl", ".json")
|
JSONL_EXTS = (".jsonl", ".json")
|
||||||
DOC_DIR = "serverAppConfigs-docs"
|
DOC_DIR = "serverAppConfigs-docs"
|
||||||
|
FRAGMENT_TOKENS = 2400 # what one fragment may contribute
|
||||||
|
CLASS_MIX = {"code": .45, "prose": .30, "tools": .25}
|
||||||
|
HF_CAP = {"code": 40000, "prose": 60000, "tools": 0}
|
||||||
|
POOL_CYCLES = 40 # how many times a class pool may repeat before giving up
|
||||||
|
KLD_GUARD = 5000 # rendered conversations tried before declaring the pool short
|
||||||
|
|
||||||
FLEET_CMDS = [
|
FLEET_CMDS = [
|
||||||
"systemctl status frank-archon --no-pager",
|
"systemctl status frank-archon --no-pager",
|
||||||
@@ -142,7 +153,7 @@ def load_parquet_texts(path: str, kind: str, rng: random.Random,
|
|||||||
return out[:cap]
|
return out[:cap]
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def parse_args() -> argparse.Namespace:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("--repo", required=True)
|
ap.add_argument("--repo", required=True)
|
||||||
ap.add_argument("--tokenizer-json", required=True)
|
ap.add_argument("--tokenizer-json", required=True)
|
||||||
@@ -156,22 +167,26 @@ def main() -> None:
|
|||||||
ap.add_argument("--hf-code-parquet", default="")
|
ap.add_argument("--hf-code-parquet", default="")
|
||||||
ap.add_argument("--hf-prose-parquet", default="")
|
ap.add_argument("--hf-prose-parquet", default="")
|
||||||
ap.add_argument("--seed", type=int, default=20261006)
|
ap.add_argument("--seed", type=int, default=20261006)
|
||||||
a = ap.parse_args()
|
return ap.parse_args()
|
||||||
|
|
||||||
rng = random.Random(a.seed)
|
|
||||||
tokenizer = Tokenizer.from_file(a.tokenizer_json)
|
def load_template(template: str) -> tuple[Environment, str]:
|
||||||
env = Environment(loader=FileSystemLoader(os.path.dirname(a.template)),
|
"""The chat template as the engine will render it, plus its short name."""
|
||||||
|
env = Environment(loader=FileSystemLoader(os.path.dirname(template)),
|
||||||
trim_blocks=True, lstrip_blocks=True)
|
trim_blocks=True, lstrip_blocks=True)
|
||||||
env.filters["tojson"] = lambda v: json.dumps(v, ensure_ascii=False)
|
env.filters["tojson"] = lambda v: json.dumps(v, ensure_ascii=False)
|
||||||
env.filters["raise_exception"] = lambda m: (_ for _ in ()).throw(
|
env.filters["raise_exception"] = lambda m: (_ for _ in ()).throw(
|
||||||
ValueError(m))
|
ValueError(m))
|
||||||
tname = os.path.basename(a.template)
|
return env, os.path.basename(template)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(repo: str) -> dict[str, list[str]]:
|
||||||
|
"""Walk the checkout into the three source classes the mix draws from."""
|
||||||
code, prose, jsonl = [], [], []
|
code, prose, jsonl = [], [], []
|
||||||
for root, dirs, files in os.walk(a.repo):
|
for root, dirs, files in os.walk(repo):
|
||||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||||
for f in sorted(files):
|
for f in sorted(files):
|
||||||
p = os.path.relpath(os.path.join(root, f), a.repo)
|
p = os.path.relpath(os.path.join(root, f), repo)
|
||||||
if DOC_DIR in p and f.endswith(".md"):
|
if DOC_DIR in p and f.endswith(".md"):
|
||||||
prose.append(p)
|
prose.append(p)
|
||||||
elif p.startswith(DOC_DIR):
|
elif p.startswith(DOC_DIR):
|
||||||
@@ -180,137 +195,185 @@ def main() -> None:
|
|||||||
code.append(p)
|
code.append(p)
|
||||||
elif f.endswith(JSONL_EXTS) and f != "package-lock.json":
|
elif f.endswith(JSONL_EXTS) and f != "package-lock.json":
|
||||||
jsonl.append(p)
|
jsonl.append(p)
|
||||||
# file-level split: bucket 0 -> KLD pool, 1..4 -> calibration pool
|
return {"code": code, "prose": prose, "tools": jsonl}
|
||||||
pools: dict[str, tuple[list, list]] = {}
|
|
||||||
for cls, lst in (("code", code), ("prose", prose), ("tools", jsonl)):
|
|
||||||
pools[cls] = ([p for p in lst if fnv(p) % 5 == 0],
|
|
||||||
[p for p in lst if fnv(p) % 5 != 0])
|
|
||||||
|
|
||||||
def text_of(p: str) -> str:
|
|
||||||
path = os.path.join(a.repo, p)
|
|
||||||
raw = open(path, encoding="utf-8", errors="replace").read()
|
|
||||||
if p.endswith(JSONL_EXTS):
|
|
||||||
lines = [json.loads(l)["content"] for l in raw.splitlines()
|
|
||||||
if l.strip() and "content" in l]
|
|
||||||
raw = "\n".join(lines)
|
|
||||||
toks = tokenizer.encode(raw, add_special_tokens=False).ids
|
|
||||||
return tokenizer.decode(toks[:2400], skip_special_tokens=True)
|
|
||||||
|
|
||||||
|
def split_pools(classes: dict[str, list[str]]) -> dict[str, tuple[list, list]]:
|
||||||
|
"""File-level split: bucket 0 -> KLD pool, everything else -> calibration."""
|
||||||
|
return {cls: ([p for p in lst if fnv(p) % 5 == 0],
|
||||||
|
[p for p in lst if fnv(p) % 5 != 0])
|
||||||
|
for cls, lst in classes.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def jsonl_content(raw: str) -> str:
|
||||||
|
"""The content fields of a content-keyed JSONL, or "" for anything else.
|
||||||
|
|
||||||
|
A .json in this repo is not necessarily a content-keyed JSONL: the substring
|
||||||
|
test passes on any line that merely mentions "content", and a list-shaped
|
||||||
|
document raises TypeError — both used to crash the whole build. Skip what
|
||||||
|
does not fit instead.
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if not line.strip() or "content" not in line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(rec, dict) and isinstance(rec.get("content"), str):
|
||||||
|
lines.append(rec["content"])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def fragment_text(repo: str, rel: str, tok) -> str:
|
||||||
|
"""One file as a fragment: its first FRAGMENT_TOKENS tokens, decoded."""
|
||||||
|
raw = open(os.path.join(repo, rel), encoding="utf-8",
|
||||||
|
errors="replace").read()
|
||||||
|
if rel.endswith(JSONL_EXTS):
|
||||||
|
raw = jsonl_content(raw)
|
||||||
|
ids = tok.encode(raw, add_special_tokens=False).ids
|
||||||
|
return tok.decode(ids[:FRAGMENT_TOKENS], skip_special_tokens=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_frags(a: argparse.Namespace, tok,
|
||||||
|
rng: random.Random) -> dict[str, dict[str, list[str]]]:
|
||||||
|
"""Both fragment pools per class, seeded so the same seed rebuilds the corpus."""
|
||||||
|
pools = split_pools(classify(a.repo))
|
||||||
cmds = harvest_cmds(a.repo, rng)
|
cmds = harvest_cmds(a.repo, rng)
|
||||||
# fragment pools per class, corpus-split first
|
|
||||||
frags: dict[str, dict[str, list[str]]] = {}
|
frags: dict[str, dict[str, list[str]]] = {}
|
||||||
for cls in pools:
|
for cls, (kld_files, cal_files) in pools.items():
|
||||||
kld_files, cal_files = pools[cls]
|
|
||||||
fr = {}
|
|
||||||
src = {"kld": kld_files, "cal": cal_files}
|
|
||||||
if cls == "tools":
|
if cls == "tools":
|
||||||
fr["kld"] = [tool_call(rng, cmds) for _ in range(600)]
|
frags[cls] = {"kld": [tool_call(rng, cmds) for _ in range(600)],
|
||||||
fr["cal"] = [tool_call(rng, cmds) for _ in range(40000)]
|
"cal": [tool_call(rng, cmds) for _ in range(40000)]}
|
||||||
else:
|
else:
|
||||||
fr["kld"] = [text_of(p) for p in src["kld"]]
|
frags[cls] = {"kld": [fragment_text(a.repo, p, tok) for p in kld_files],
|
||||||
fr["cal"] = [text_of(p) for p in src["cal"]]
|
"cal": [fragment_text(a.repo, p, tok) for p in cal_files]}
|
||||||
frags[cls] = fr
|
|
||||||
# per-class fragment caps: prose rows are short (~66 tok) so need many;
|
# per-class fragment caps: prose rows are short (~66 tok) so need many;
|
||||||
# codeparrot files are long (~2.6k tok) so a small cap suffices
|
# codeparrot files are long (~2.6k tok) so a small cap suffices
|
||||||
hfcap = {"code": 40000, "prose": 60000, "tools": 0}
|
|
||||||
for cls, spec in (("code", a.hf_code_parquet),
|
for cls, spec in (("code", a.hf_code_parquet),
|
||||||
("prose", a.hf_prose_parquet)):
|
("prose", a.hf_prose_parquet)):
|
||||||
for path in [p for p in spec.split(",") if p]:
|
for path in [p for p in spec.split(",") if p]:
|
||||||
frags[cls]["cal"].extend(
|
frags[cls]["cal"].extend(
|
||||||
load_parquet_texts(path, cls, rng, cap=hfcap[cls]))
|
load_parquet_texts(path, cls, rng, cap=HF_CAP[cls]))
|
||||||
for cls in frags:
|
for cls in frags:
|
||||||
rng.shuffle(frags[cls]["cal"])
|
rng.shuffle(frags[cls]["cal"])
|
||||||
|
return frags
|
||||||
|
|
||||||
def toks(s: str) -> int:
|
|
||||||
return len(tokenizer.encode(s, add_special_tokens=False).ids)
|
|
||||||
|
|
||||||
def emit_calib() -> tuple[int, list[dict]]:
|
def pack_class(cls: str, pool: list[str], want: int, tok,
|
||||||
targets = {"code": .45, "prose": .30, "tools": .25}
|
chunk_tokens: int) -> list[dict]:
|
||||||
rows: list[dict] = []
|
"""Greedily pack `pool` into chunk_tokens-sized calibration prompts."""
|
||||||
for cls, frac in targets.items():
|
if not pool:
|
||||||
want = int(a.calib_tokens * frac)
|
return []
|
||||||
pool = frags[cls]["cal"]
|
rows: list[dict] = []
|
||||||
|
# Count every fragment once: the loop cycles the pool up to POOL_CYCLES
|
||||||
|
# times, and re-tokenizing a 2.4k-token fragment on each pass dominated the
|
||||||
|
# build's wall time.
|
||||||
|
counts = [qc.n_tokens(tok, f) for f in pool]
|
||||||
|
got = 0
|
||||||
|
i = 0
|
||||||
|
buf: list[str] = []
|
||||||
|
nbuf = 0
|
||||||
|
while got < want:
|
||||||
|
j = i % len(pool)
|
||||||
|
frag = pool[j]
|
||||||
|
i += 1
|
||||||
|
n = counts[j]
|
||||||
|
if nbuf + n > chunk_tokens and buf:
|
||||||
|
rows.append({"prompt": "\n".join(buf), "cls": cls})
|
||||||
|
got += nbuf
|
||||||
|
buf, nbuf = [], 0
|
||||||
|
if got >= want:
|
||||||
|
break
|
||||||
|
buf.append(frag)
|
||||||
|
nbuf += n
|
||||||
|
if i > len(pool) * POOL_CYCLES:
|
||||||
|
break # pool exhausted with repeats; stop
|
||||||
|
if buf and got < want:
|
||||||
|
rows.append({"prompt": "\n".join(buf), "cls": cls})
|
||||||
|
got += nbuf
|
||||||
|
print(f"calib {cls}: {got} tokens, {i} fragments")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def emit_calib(a: argparse.Namespace, frags, tok,
|
||||||
|
rng: random.Random) -> tuple[int, list[dict]]:
|
||||||
|
"""calib.jsonl rows: the class mix, shuffled, prompts only."""
|
||||||
|
rows: list[dict] = []
|
||||||
|
for cls, frac in CLASS_MIX.items():
|
||||||
|
rows.extend(pack_class(cls, frags[cls]["cal"], int(a.calib_tokens * frac),
|
||||||
|
tok, a.chunk_tokens))
|
||||||
|
rng.shuffle(rows)
|
||||||
|
return len(rows), [{"prompt": r["prompt"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def emit_kld(a: argparse.Namespace, frags, tok, rng: random.Random,
|
||||||
|
env: Environment, tname: str) -> tuple[int, list[dict], int]:
|
||||||
|
"""kld.jsonl rows: multi-turn conversations, each with a scored tail."""
|
||||||
|
template = env.get_template(tname)
|
||||||
|
rows: list[dict] = []
|
||||||
|
total = scored_total = 0
|
||||||
|
classes = ["code", "prose", "tools"]
|
||||||
|
cursor = {c: 0 for c in classes}
|
||||||
|
guard = 0
|
||||||
|
while total < a.kld_tokens and guard < KLD_GUARD:
|
||||||
|
guard += 1
|
||||||
|
msgs, srcs = [], []
|
||||||
|
for t in range(rng.choice([2, 3, 4])):
|
||||||
|
cls = classes[(len(rows) + t) % 3]
|
||||||
|
pool = frags[cls]["kld"]
|
||||||
if not pool:
|
if not pool:
|
||||||
continue
|
continue
|
||||||
got, i, buf, nbuf = 0, 0, [], 0
|
frag = pool[cursor[cls] % len(pool)]
|
||||||
while got < want:
|
cursor[cls] += 1
|
||||||
frag = pool[i % len(pool)]
|
msgs.append({"role": "user",
|
||||||
i += 1
|
"content": "Summarize and explain this "
|
||||||
n = toks(frag)
|
f"engineering material:\n{frag}"})
|
||||||
if nbuf + n > a.chunk_tokens and buf:
|
msgs.append({"role": "assistant",
|
||||||
rows.append({"prompt": "\n".join(buf), "cls": cls})
|
"content": frag[:4000]})
|
||||||
got += nbuf
|
srcs.append(cls)
|
||||||
buf, nbuf = [], 0
|
rendered = template.render(messages=msgs, add_generation_prompt=False)
|
||||||
if got >= want:
|
n = qc.n_tokens(tok, rendered)
|
||||||
break
|
if n < a.kld_min_prompt:
|
||||||
buf.append(frag)
|
continue
|
||||||
nbuf += n
|
rows.append({"prompt": rendered,
|
||||||
if i > len(pool) * 40:
|
"score_from": n - a.score_tail,
|
||||||
break # pool exhausted with repeats; stop
|
"source": "+".join(sorted(set(srcs)))})
|
||||||
if buf and got < want:
|
total += n
|
||||||
rows.append({"prompt": "\n".join(buf), "cls": cls})
|
scored_total += a.score_tail
|
||||||
got += nbuf
|
return len(rows), rows, scored_total
|
||||||
print(f"calib {cls}: {got} tokens, {i} fragments")
|
|
||||||
rng.shuffle(rows)
|
|
||||||
return len(rows), [{k: v for k, v in r.items() if k == "prompt"}
|
|
||||||
for r in rows]
|
|
||||||
|
|
||||||
def emit_kld() -> tuple[int, list[dict]]:
|
|
||||||
rows, total = [], 0
|
|
||||||
classes = ["code", "prose", "tools"]
|
|
||||||
i = {c: 0 for c in classes}
|
|
||||||
guard = 0
|
|
||||||
while total < a.kld_tokens and guard < 5000:
|
|
||||||
guard += 1
|
|
||||||
msgs, srcs = [], []
|
|
||||||
for t in range(rng.choice([2, 3, 4])):
|
|
||||||
cls = classes[(len(rows) + t) % 3]
|
|
||||||
pool = frags[cls]["kld"]
|
|
||||||
if not pool:
|
|
||||||
continue
|
|
||||||
frag = pool[i[cls] % len(pool)]
|
|
||||||
i[cls] += 2
|
|
||||||
msgs.append({"role": "user",
|
|
||||||
"content": "Summarize and explain this "
|
|
||||||
f"engineering material:\n{frag}"})
|
|
||||||
msgs.append({"role": "assistant",
|
|
||||||
"content": frag[:4000]})
|
|
||||||
srcs.append(cls)
|
|
||||||
rendered = env.get_template(tname).render(
|
|
||||||
messages=msgs, add_generation_prompt=False)
|
|
||||||
n = toks(rendered)
|
|
||||||
if n < a.kld_min_prompt:
|
|
||||||
continue
|
|
||||||
rows.append({"prompt": rendered,
|
|
||||||
"score_from": n - a.score_tail,
|
|
||||||
"source": "+".join(sorted(set(srcs)))})
|
|
||||||
total += n
|
|
||||||
return len(rows), rows
|
|
||||||
|
|
||||||
os.makedirs(a.out_dir, exist_ok=True)
|
def manifest(a: argparse.Namespace, tname: str, nc: int, nk: int,
|
||||||
nc, rows = emit_calib()
|
scored: int) -> dict:
|
||||||
with open(os.path.join(a.out_dir, "calib.jsonl"), "w") as f:
|
return {
|
||||||
for r in rows:
|
|
||||||
f.write(json.dumps(r) + "\n")
|
|
||||||
nk, krows = emit_kld()
|
|
||||||
with open(os.path.join(a.out_dir, "kld.jsonl"), "w") as f:
|
|
||||||
for r in krows:
|
|
||||||
f.write(json.dumps(r) + "\n")
|
|
||||||
scored = sum(len(tokenizer.encode(r["prompt"], add_special_tokens=False).ids)
|
|
||||||
- r["score_from"] for r in krows)
|
|
||||||
manifest = {
|
|
||||||
"seed": a.seed, "template": tname,
|
"seed": a.seed, "template": tname,
|
||||||
"tokenizer": os.path.basename(a.tokenizer_json),
|
"tokenizer": os.path.basename(a.tokenizer_json),
|
||||||
"calib_prompts": nc, "kld_prompts": nk,
|
"calib_prompts": nc, "kld_prompts": nk,
|
||||||
"kld_scored_positions": scored,
|
"kld_scored_positions": scored,
|
||||||
"kld_score_tail": a.score_tail,
|
"kld_score_tail": a.score_tail,
|
||||||
"disjoint_file_pools": "local KLD material only; HF code feeds calib",
|
"disjoint_file_pools": "local KLD material only; HF code feeds calib",
|
||||||
"calib_mix": {"code": .45, "prose": .30, "tools": .25},
|
"calib_mix": dict(CLASS_MIX),
|
||||||
"hf_code": a.hf_code_parquet, "hf_prose": a.hf_prose_parquet,
|
"hf_code": a.hf_code_parquet, "hf_prose": a.hf_prose_parquet,
|
||||||
}
|
}
|
||||||
with open(os.path.join(a.out_dir, "manifest.json"), "w") as f:
|
|
||||||
json.dump(manifest, f, indent=1)
|
|
||||||
print(json.dumps(manifest, indent=1))
|
def main() -> None:
|
||||||
|
a = parse_args()
|
||||||
|
rng = random.Random(a.seed)
|
||||||
|
tok = qc.load_tokenizer(a.tokenizer_json)
|
||||||
|
env, tname = load_template(a.template)
|
||||||
|
|
||||||
|
frags = build_frags(a, tok, rng)
|
||||||
|
os.makedirs(a.out_dir, exist_ok=True)
|
||||||
|
nc, calib_rows = emit_calib(a, frags, tok, rng)
|
||||||
|
qc.write_jsonl(os.path.join(a.out_dir, "calib.jsonl"), calib_rows)
|
||||||
|
nk, kld_rows, scored = emit_kld(a, frags, tok, rng, env, tname)
|
||||||
|
qc.write_jsonl(os.path.join(a.out_dir, "kld.jsonl"), kld_rows)
|
||||||
|
man = manifest(a, tname, nc, nk, scored)
|
||||||
|
qc.write_json(os.path.join(a.out_dir, "manifest.json"), man)
|
||||||
|
print(json.dumps(man, indent=1))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Executable
+420
@@ -0,0 +1,420 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""media-ab-reorg.py — plan the Audiobooks dedupe + ABS-structure relayout.
|
||||||
|
|
||||||
|
2026-11-04 campaign. Turns the Audiobooks library into the layout Audiobookshelf
|
||||||
|
documents (https://audiobookshelf.org/docs/documentation/libraries/book-library/
|
||||||
|
directory-structure/):
|
||||||
|
|
||||||
|
{Author}/[{Series}/]{seq} - {year} - {Title} {Narrator}/…files…
|
||||||
|
|
||||||
|
The authority for every book's author/series/title/year/narrator is the ABS
|
||||||
|
database dump (--items, JSON lines: [relPath, isMissing, title, authors, series,
|
||||||
|
seq, year, narratorsJSON, coverPath, ebookFlag]); files are attached to their
|
||||||
|
book by longest-prefix match on the item's relPath, which is how ABS's own
|
||||||
|
grouping works. Folder names are then re-emitted so ABS's folder parser reads
|
||||||
|
the same metadata back off disk.
|
||||||
|
|
||||||
|
Three outputs, all consumable by scripts/media-apply.py (run everything with
|
||||||
|
--root /mnt/entropy; hold/intake/park live outside the library on purpose):
|
||||||
|
|
||||||
|
--plan mkdir/keep/move rows: dedupe losers -> --hold, ebook-only items
|
||||||
|
and comics -> --intake (the eBooks pile feeds Calibre next),
|
||||||
|
unowned media -> --park, everything else -> the new layout.
|
||||||
|
--covers rows "srchost \\t dstarray": covers ABS keeps in /metadata get a
|
||||||
|
copy beside the book (cp, it crosses filesystems); run this
|
||||||
|
BEFORE apply so the cover lands inside the moved folder.
|
||||||
|
--exceptions every judgement call: name collisions, byte-dups that pair two
|
||||||
|
different books, collapsed items, stray files.
|
||||||
|
|
||||||
|
Dedupe law: only CONFIRMED byte-identities (--dups from
|
||||||
|
media-ab-verifydups.py) remove a file. One copy survives per book: inside a
|
||||||
|
folder the cleanest-named file wins; across folders the item whose root matches
|
||||||
|
its author wins. Equal bytes under two different book identities keep both and
|
||||||
|
raise an exception — a trailer embedded in two books is not a duplicate.
|
||||||
|
|
||||||
|
Rerunnable: a second run against a moved library emits keep rows, not moves.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
AUDIO = {"m4b", "mp3", "m4a", "flac", "opus", "ogg", "oga", "mp4", "aac", "wma",
|
||||||
|
"aiff", "aif", "wav", "webm", "webma", "mka", "awb", "caf", "mpg",
|
||||||
|
"mpg2", "mpeg"}
|
||||||
|
EBOOK = {"epub", "pdf", "mobi", "azw3", "cbr", "cbz", "fb2"}
|
||||||
|
COMIC = {"cbr", "cbz"}
|
||||||
|
MEDIA = AUDIO | {"epub", "pdf", "mobi", "azw3", "cbr", "cbz"}
|
||||||
|
PLACEHOLDER_AUTHOR = {"unknown", "unknown artist", "various", "various artists",
|
||||||
|
"various authors", "n/a", ""}
|
||||||
|
BAD_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||||
|
DISC_DIR = re.compile(r"^(disc|cd|disk)\s*\d", re.I)
|
||||||
|
KEEP_HINT = re.compile(r"\s*\((?:2|3|copy|backup|duplicate)\)\s*$", re.I)
|
||||||
|
META_HOST = "/home/dank/Containers/audiobookshelf"
|
||||||
|
|
||||||
|
|
||||||
|
def clean(name):
|
||||||
|
name = BAD_CHARS.sub(" ", name or "")
|
||||||
|
name = re.sub(r"\s+", " ", name).strip().strip(".")
|
||||||
|
return name or "Untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def clean_author(name):
|
||||||
|
"""DB author string -> folder name: one author, no 'zz' sort artifact."""
|
||||||
|
name = re.sub(r"^zz", "", (name or "").strip())
|
||||||
|
return clean(name)
|
||||||
|
|
||||||
|
|
||||||
|
def first_author(authors):
|
||||||
|
parts = [clean_author(p) for p in (authors or "").split("|")]
|
||||||
|
parts = [p for p in parts if p]
|
||||||
|
return parts[0] if parts else "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_narrators(blob):
|
||||||
|
try:
|
||||||
|
v = json.loads(blob) if blob else []
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
v = []
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = [v]
|
||||||
|
return [str(x).strip() for x in (v or []) if str(x).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def core_title(title, author):
|
||||||
|
"""Title as the book folder should show it: release prefixes stripped."""
|
||||||
|
t = (title or "Untitled").strip()
|
||||||
|
a = (author or "").strip()
|
||||||
|
if a and t.lower().startswith(a.lower() + " -"):
|
||||||
|
t = t[len(a):].lstrip(" -")
|
||||||
|
return t.strip() or "Untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def bookname(seq, year, title, narrators):
|
||||||
|
seq = (seq or "").strip()
|
||||||
|
seq = seq if re.fullmatch(r"\d+(\.\d+)?", seq) else ""
|
||||||
|
year = (year or "").strip()
|
||||||
|
year = year if re.fullmatch(r"(1[5-9]|20)\d\d", year) else ""
|
||||||
|
t = title
|
||||||
|
if seq:
|
||||||
|
# drop a sequence prefix already carried inside the title
|
||||||
|
t = re.sub(rf"^{re.escape(seq)}\s*[\-.]\s+", "", t)
|
||||||
|
if year:
|
||||||
|
t = re.sub(rf"^{year}\s*[\-.]\s+", "", t)
|
||||||
|
t = re.sub(rf"\s*\({year}\)\s*$", "", t)
|
||||||
|
name = t
|
||||||
|
if seq and year:
|
||||||
|
name = f"{seq} - {year} - {name}"
|
||||||
|
elif seq:
|
||||||
|
name = f"{seq} - {name}"
|
||||||
|
elif year:
|
||||||
|
name = f"{year} - {name}"
|
||||||
|
if len(narrators) == 1:
|
||||||
|
name = f"{name} {{{clean(narrators[0])}}}"
|
||||||
|
return clean(name)
|
||||||
|
|
||||||
|
|
||||||
|
class Campaign:
|
||||||
|
def __init__(self, args):
|
||||||
|
self.root = args.root.rstrip("/")
|
||||||
|
self.lib = self.root + "/Audiobooks"
|
||||||
|
self.hold = args.hold
|
||||||
|
self.intake = args.intake
|
||||||
|
self.park = args.park
|
||||||
|
self.items = {} # relPath -> item dict (healthy only)
|
||||||
|
self.stale = [] # isMissing=1 rows
|
||||||
|
self.moved = {} # src -> (dst, kind)
|
||||||
|
self.claims = {} # dst -> src (collision guard)
|
||||||
|
self.loser_files = set()
|
||||||
|
self.loser_items = set()
|
||||||
|
self.exc = defaultdict(list)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- inputs
|
||||||
|
def load_items(self, path):
|
||||||
|
for line in open(path):
|
||||||
|
r = json.loads(line)
|
||||||
|
rel = r[0]
|
||||||
|
item = {"rel": rel, "title": r[2] or "", "authors": r[3] or "",
|
||||||
|
"series": r[4] or "", "seq": r[5] or "", "year": r[6] or "",
|
||||||
|
"narr": parse_narrators(r[7]), "cover": r[8] or "",
|
||||||
|
"ebook": r[9]}
|
||||||
|
if r[1]:
|
||||||
|
self.stale.append(rel)
|
||||||
|
continue
|
||||||
|
self.items[rel] = item
|
||||||
|
|
||||||
|
def load_files(self, path):
|
||||||
|
files = []
|
||||||
|
for line in open(path):
|
||||||
|
sz, ext, p = line.rstrip("\n").split("\t", 2)
|
||||||
|
if p.startswith(self.lib + "/"):
|
||||||
|
files.append((int(sz), ext.lstrip(".").lower(), p))
|
||||||
|
return files
|
||||||
|
|
||||||
|
def owner(self, d):
|
||||||
|
"""The healthy DB item whose folder is d or an ancestor of d."""
|
||||||
|
rel = os.path.relpath(d, self.lib)
|
||||||
|
while rel and rel != ".":
|
||||||
|
if rel in self.items:
|
||||||
|
return rel
|
||||||
|
rel = os.path.dirname(rel)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- dedupe
|
||||||
|
def identity(self, item_rel):
|
||||||
|
if item_rel is None:
|
||||||
|
return None
|
||||||
|
it = self.items[item_rel]
|
||||||
|
return (first_author(it["authors"]).lower(),
|
||||||
|
core_title(it["title"], first_author(it["authors"])).lower(),
|
||||||
|
(it["year"] or "").strip())
|
||||||
|
|
||||||
|
def root_score(self, item_rel):
|
||||||
|
it = self.items[item_rel]
|
||||||
|
top = item_rel.split("/")[0]
|
||||||
|
s = 0
|
||||||
|
if top.lower() == first_author(it["authors"]).lower():
|
||||||
|
s += 2
|
||||||
|
if not top.startswith("_") and "various" not in top.lower():
|
||||||
|
s += 1
|
||||||
|
return (s, -len(item_rel))
|
||||||
|
|
||||||
|
def dedupe(self, files, dups_path):
|
||||||
|
by_hash = defaultdict(list)
|
||||||
|
if dups_path:
|
||||||
|
for line in open(dups_path):
|
||||||
|
h, sz, p = line.rstrip("\n").split("\t", 2)
|
||||||
|
if p.startswith(self.lib + "/"):
|
||||||
|
by_hash[h].append(p)
|
||||||
|
owner_of = {}
|
||||||
|
dirs = {os.path.dirname(p) for _, _, p in files}
|
||||||
|
for d in dirs:
|
||||||
|
owner_of[d] = self.owner(d)
|
||||||
|
files_by_owner = defaultdict(list)
|
||||||
|
for _, _, p in files:
|
||||||
|
files_by_owner[owner_of[os.path.dirname(p)]].append(p)
|
||||||
|
|
||||||
|
for h, paths in by_hash.items():
|
||||||
|
owners = {owner_of.get(os.path.dirname(p)) for p in paths}
|
||||||
|
owners = {o for o in owners if o is not None}
|
||||||
|
per_item = defaultdict(list)
|
||||||
|
for p in paths:
|
||||||
|
per_item[owner_of.get(os.path.dirname(p))].append(p)
|
||||||
|
if owners:
|
||||||
|
ids = {o: self.identity(o) for o in owners}
|
||||||
|
# group items by identity; loose (None) files follow their own rule
|
||||||
|
same_as_loser = []
|
||||||
|
keepers = []
|
||||||
|
byid = defaultdict(list)
|
||||||
|
for o in owners:
|
||||||
|
byid[ids[o]].append(o)
|
||||||
|
for ident, os_ in byid.items():
|
||||||
|
os_.sort(key=self.root_score, reverse=True)
|
||||||
|
keepers.append(os_[0])
|
||||||
|
same_as_loser.extend(os_[1:])
|
||||||
|
keepers = set(keepers)
|
||||||
|
loser_items = set(same_as_loser)
|
||||||
|
else:
|
||||||
|
loser_items = set()
|
||||||
|
keepers = set()
|
||||||
|
for o, ps in per_item.items():
|
||||||
|
if o is None:
|
||||||
|
continue # loose files decided in placement
|
||||||
|
if o in loser_items:
|
||||||
|
self.loser_files.update(ps)
|
||||||
|
continue
|
||||||
|
if len(ps) > 1 and o in keepers:
|
||||||
|
# redundant copies inside the surviving folder
|
||||||
|
keeper = min(ps, key=lambda p: (
|
||||||
|
bool(KEEP_HINT.search(os.path.splitext(p)[0])),
|
||||||
|
len(p), p))
|
||||||
|
self.loser_files.update(p for p in ps if p != keeper)
|
||||||
|
for o in loser_items:
|
||||||
|
self.loser_items.add(o)
|
||||||
|
# collapsed items: every media file lost
|
||||||
|
for o, ps in files_by_owner.items():
|
||||||
|
if o is None:
|
||||||
|
continue
|
||||||
|
media = [p for p in ps if os.path.splitext(p)[1].lstrip(".").lower()
|
||||||
|
in MEDIA]
|
||||||
|
if media and all(p in self.loser_files for p in media):
|
||||||
|
self.loser_items.add(o)
|
||||||
|
self.exc["collapsed items (all files were byte-duplicates)"].append(o)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- placement
|
||||||
|
def target(self, item):
|
||||||
|
author = first_author(item["authors"])
|
||||||
|
series = clean(item["series"]) if item["series"] else ""
|
||||||
|
title = core_title(item["title"], author)
|
||||||
|
name = bookname(item["seq"], item["year"], title, item["narr"])
|
||||||
|
parts = [self.lib, clean(author)]
|
||||||
|
if series:
|
||||||
|
parts.append(clean(series))
|
||||||
|
parts.append(name)
|
||||||
|
return os.path.join(*parts)
|
||||||
|
|
||||||
|
def claim(self, dst, src, why):
|
||||||
|
"""One destination, one source; collisions rename with a marker."""
|
||||||
|
if dst not in self.claims:
|
||||||
|
self.claims[dst] = src
|
||||||
|
return dst
|
||||||
|
stem, ext = os.path.splitext(dst)
|
||||||
|
n = 2
|
||||||
|
while f"{stem} ({n}){ext}" in self.claims:
|
||||||
|
n += 1
|
||||||
|
dst2 = f"{stem} ({n}){ext}"
|
||||||
|
self.claims[dst2] = src
|
||||||
|
self.exc[why].append(f"{src} -> renamed to {dst2}")
|
||||||
|
return dst2
|
||||||
|
|
||||||
|
def place(self, files):
|
||||||
|
audio_by_item = defaultdict(int)
|
||||||
|
for _, ext, p in files:
|
||||||
|
o = self.owner(os.path.dirname(p))
|
||||||
|
if o and ext in AUDIO:
|
||||||
|
audio_by_item[o] += 1
|
||||||
|
cover_dirs = {}
|
||||||
|
# Claim one BOOK FOLDER per item: two items for the same title (the
|
||||||
|
# _unsorted era filed some books 3 times) must not merge their tracks
|
||||||
|
# into one item — the loser gets a variant folder instead.
|
||||||
|
for rel in sorted(self.items, key=lambda r: self.root_score(r),
|
||||||
|
reverse=True):
|
||||||
|
it = self.items[rel]
|
||||||
|
t = self.target(it)
|
||||||
|
if t in self.claims:
|
||||||
|
n = 2
|
||||||
|
while f"{t} (variant {n})" in self.claims:
|
||||||
|
n += 1
|
||||||
|
t = f"{t} (variant {n})"
|
||||||
|
self.exc["same title filed by two DB items"].append(f"{rel} -> {t}")
|
||||||
|
self.claims[t] = rel
|
||||||
|
cover_dirs[rel] = t
|
||||||
|
if (it["cover"].startswith("/metadata") and audio_by_item[rel]
|
||||||
|
and rel not in self.loser_items):
|
||||||
|
src = META_HOST + it["cover"]
|
||||||
|
self.covers.append((src, os.path.join(t,
|
||||||
|
"cover" + os.path.splitext(src)[1].lower())))
|
||||||
|
# Containment gate: an item folder holding media files must never sit
|
||||||
|
# above another item folder — ABS's grouping would swallow the child
|
||||||
|
# (the docs' PROBLEM example). Rename the ANCESTOR book out of the way.
|
||||||
|
for _ in range(3):
|
||||||
|
moved = False
|
||||||
|
folders = sorted(set(cover_dirs.values()))
|
||||||
|
for anc in folders:
|
||||||
|
for d in folders:
|
||||||
|
if d != anc and d.startswith(anc + os.sep):
|
||||||
|
n = 1
|
||||||
|
cand = f"{anc} (book)" if n == 1 else f"{anc} (book {n})"
|
||||||
|
while cand in self.claims:
|
||||||
|
n += 1
|
||||||
|
cand = f"{anc} (book {n})"
|
||||||
|
self.exc["book folder nested above another book"].append(
|
||||||
|
f"{anc} -> {cand}")
|
||||||
|
self.claims[cand] = self.claims.pop(anc)
|
||||||
|
for r, t in cover_dirs.items():
|
||||||
|
if t == anc:
|
||||||
|
cover_dirs[r] = cand
|
||||||
|
self.covers = [(s, d.replace(anc + os.sep, cand + os.sep, 1))
|
||||||
|
for s, d in self.covers]
|
||||||
|
moved = True
|
||||||
|
break
|
||||||
|
if moved:
|
||||||
|
break
|
||||||
|
if not moved:
|
||||||
|
break
|
||||||
|
for _, ext, p in files:
|
||||||
|
if p in self.loser_files:
|
||||||
|
d = os.path.join(self.hold, os.path.relpath(p, self.root))
|
||||||
|
self.move(p, d, "dup")
|
||||||
|
continue
|
||||||
|
d = os.path.dirname(p)
|
||||||
|
o = self.owner(d)
|
||||||
|
if o is None:
|
||||||
|
rel = os.path.relpath(p, self.lib)
|
||||||
|
if ext in MEDIA:
|
||||||
|
self.move(p, os.path.join(self.park, rel), "park")
|
||||||
|
else:
|
||||||
|
self.move(p, os.path.join(self.park, rel), "park-junk")
|
||||||
|
continue
|
||||||
|
it = self.items[o]
|
||||||
|
if ext in COMIC:
|
||||||
|
key = clean(f"{first_author(it['authors'])} - {core_title(it['title'], '')}")
|
||||||
|
dst = os.path.join(self.intake, "_comics", key, os.path.basename(p))
|
||||||
|
self.move(p, self.claim(dst, p, "intake comic collisions"), "comic")
|
||||||
|
continue
|
||||||
|
if audio_by_item[o] == 0: # ebook-only item belongs to the ebook stack
|
||||||
|
key = f"{first_author(it['authors'])} - {bookname(it['seq'], it['year'], core_title(it['title'], first_author(it['authors'])), it['narr'])}"
|
||||||
|
dst = os.path.join(self.intake, key, os.path.relpath(p, os.path.join(self.lib, o)))
|
||||||
|
self.move(p, self.claim(dst, p, "intake ebook collisions"), "ebook-only")
|
||||||
|
continue
|
||||||
|
rel_in = os.path.relpath(p, os.path.join(self.lib, o))
|
||||||
|
dst = os.path.join(cover_dirs[o], rel_in)
|
||||||
|
if dst == p:
|
||||||
|
self.kept.append(p)
|
||||||
|
self.claims.setdefault(dst, p)
|
||||||
|
continue
|
||||||
|
self.move(p, self.claim(dst, p, "target collisions"), "move")
|
||||||
|
|
||||||
|
def move(self, src, dst, kind):
|
||||||
|
self.moves.append((src, dst, kind))
|
||||||
|
|
||||||
|
def write(self, plan_path, exc_path):
|
||||||
|
dirs = {os.path.dirname(dst) for _, dst, _ in self.moves}
|
||||||
|
dirs |= {os.path.dirname(d) for _, d in self.covers}
|
||||||
|
all_dirs = set()
|
||||||
|
for d in dirs:
|
||||||
|
while d and d != self.root:
|
||||||
|
all_dirs.add(d)
|
||||||
|
d = os.path.dirname(d)
|
||||||
|
with open(plan_path, "w") as fh:
|
||||||
|
fh.write("action\tsrc\tdst\tkind\n")
|
||||||
|
for d in sorted(all_dirs):
|
||||||
|
fh.write(f"mkdir\t\t{d}\tcampaign\n")
|
||||||
|
for p in self.kept:
|
||||||
|
fh.write(f"keep\t{p}\t\tkeep\n")
|
||||||
|
for src, dst, kind in sorted(self.moves):
|
||||||
|
fh.write(f"move\t{src}\t{dst}\t{kind}\n")
|
||||||
|
if self.exc:
|
||||||
|
with open(exc_path, "w") as fh:
|
||||||
|
for why in sorted(self.exc):
|
||||||
|
fh.write(f"# {why}\n")
|
||||||
|
for line in sorted(set(self.exc[why])):
|
||||||
|
fh.write(line + "\n")
|
||||||
|
fh.write("\n")
|
||||||
|
mv = [m for m in self.moves if m[2] != "dup"]
|
||||||
|
print(f"plan: {len(all_dirs)} folders, {len(self.moves)} moves "
|
||||||
|
f"({len(mv)} relayout, {len(self.moves)-len(mv)} dup-hold), "
|
||||||
|
f"{len(self.kept)} keep, {len(self.covers)} cover-copies")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--root", default="/mnt/entropy")
|
||||||
|
ap.add_argument("--enum", required=True)
|
||||||
|
ap.add_argument("--items", required=True)
|
||||||
|
ap.add_argument("--dups", default=None)
|
||||||
|
ap.add_argument("--hold", default="/mnt/entropy/.media-campaign/dup-hold-20261104")
|
||||||
|
ap.add_argument("--intake", default="/mnt/entropy/eBooks/_from-audiobooks")
|
||||||
|
ap.add_argument("--park", default="/mnt/entropy/NeedsProcessing/_from-audiobooks-20261104")
|
||||||
|
ap.add_argument("--plan", required=True)
|
||||||
|
ap.add_argument("--exceptions", required=True)
|
||||||
|
ap.add_argument("--covers", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
c = Campaign(args)
|
||||||
|
c.moves, c.kept, c.covers = [], [], []
|
||||||
|
c.load_items(args.items)
|
||||||
|
files = c.load_files(args.enum)
|
||||||
|
c.dedupe(files, args.dups)
|
||||||
|
c.place(files)
|
||||||
|
c.write(args.plan, args.exceptions)
|
||||||
|
with open(args.covers, "w") as fh:
|
||||||
|
for src, dst in sorted(set(c.covers)):
|
||||||
|
fh.write(f"{src}\t{dst}\n")
|
||||||
|
print(f"covers: {len(set(c.covers))} stale DB rows (delete after scan): {len(c.stale)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""media-ab-verifydups.py — confirm byte-duplicates among size-group survivors.
|
||||||
|
|
||||||
|
Stage 2 of the 2026-11-04 Audiobooks dedupe. Stage 1 (partial blake2b of the
|
||||||
|
first+last 4 MiB, done by the campaign's hashcand.py) over-claims: two files
|
||||||
|
of equal size and equal head+tail are CANDIDATES, not copies. This tool takes
|
||||||
|
the candidate files from --partial (rows: size, hash, path), groups them by
|
||||||
|
(size, partial-hash), full-hashes only members of groups with 2+ members, and
|
||||||
|
writes verdicts: one line per file whose full hash ties it to at least one
|
||||||
|
other file.
|
||||||
|
|
||||||
|
Output rows: full-hash <TAB> size <TAB> path (sorted by hash) — a hash that
|
||||||
|
appears once is not written at all; singletons are not duplicates.
|
||||||
|
|
||||||
|
Read-only. Never moves, never deletes. Run it after the partial pass finishes
|
||||||
|
(pgrep it first; two hashers on one output file have raced before).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
def full_hash(path, chunk=1 << 20):
|
||||||
|
h = hashlib.blake2b(digest_size=32)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
for block in iter(lambda: fh.read(chunk), b""):
|
||||||
|
h.update(block)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--partial", required=True, help="partial-hashes.tsv")
|
||||||
|
ap.add_argument("--out", required=True, help="full-hash verdicts TSV")
|
||||||
|
ap.add_argument("--progress-every", type=int, default=50)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
groups = defaultdict(list)
|
||||||
|
with open(args.partial) as fh:
|
||||||
|
for line in fh:
|
||||||
|
f = line.rstrip("\n").split("\t")
|
||||||
|
if len(f) != 3 or f[0] == "ERR":
|
||||||
|
continue
|
||||||
|
groups[(int(f[0]), f[1])].append(f[2])
|
||||||
|
survivors = sorted({p for g in groups.values() if len(g) > 1 for p in g})
|
||||||
|
print(f"survivor files to full-hash: {len(survivors)}")
|
||||||
|
|
||||||
|
done = set()
|
||||||
|
if os.path.exists(args.out):
|
||||||
|
with open(args.out) as fh:
|
||||||
|
for line in fh:
|
||||||
|
done.add(line.rstrip("\n").split("\t", 2)[2])
|
||||||
|
full = {}
|
||||||
|
for line in open(args.out) if os.path.exists(args.out) else []:
|
||||||
|
h, s, p = line.rstrip("\n").split("\t", 2)
|
||||||
|
full[p] = h
|
||||||
|
|
||||||
|
todo = [p for p in survivors if p not in done]
|
||||||
|
out = open(args.out, "a")
|
||||||
|
for i, p in enumerate(todo):
|
||||||
|
try:
|
||||||
|
full[p] = full_hash(p)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"SKIP {p}: {e}")
|
||||||
|
continue
|
||||||
|
if i % args.progress_every == args.progress_every - 1:
|
||||||
|
out.flush()
|
||||||
|
out.close()
|
||||||
|
|
||||||
|
# Rewrite the whole verdict file keeping only TRUE duplicates (2+ fulls).
|
||||||
|
by_hash = defaultdict(list)
|
||||||
|
for p in survivors:
|
||||||
|
if p in full:
|
||||||
|
by_hash[full[p]].append(p)
|
||||||
|
with open(args.out, "w") as out:
|
||||||
|
for h, ps in sorted(by_hash.items()):
|
||||||
|
if len(ps) > 1:
|
||||||
|
for p in sorted(ps):
|
||||||
|
out.write(f"{h}\t{os.path.getsize(p)}\t{p}\n")
|
||||||
|
dups = {h: ps for h, ps in by_hash.items() if len(ps) > 1}
|
||||||
|
n = sum(len(ps) - 1 for ps in dups.values())
|
||||||
|
waste = sum((len(ps) - 1) * os.path.getsize(ps[0]) for ps in dups.values())
|
||||||
|
print(f"true dup sets: {len(dups)} redundant files: {n} "
|
||||||
|
f"waste: {waste / 2**30:.1f} GiB")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+521
@@ -0,0 +1,521 @@
|
|||||||
|
#!/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, bad = [], [], [], []
|
||||||
|
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 n, line in enumerate(fh, 2):
|
||||||
|
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 ""))
|
||||||
|
else:
|
||||||
|
bad.append(f"line {n}: unknown action {f[0]!r} — this tool"
|
||||||
|
" knows mkdir, keep and move (a rename is a move)")
|
||||||
|
if bad:
|
||||||
|
# A row this tool could not read is a plan bug, and a plan bug refuses:
|
||||||
|
# silently dropping it turns a typo into a no-op run that reports success.
|
||||||
|
head = "; ".join(bad[:3])
|
||||||
|
raise Refuse(head + (f" (+{len(bad) - 3} more)" if len(bad) > 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
+239
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""media-ebook-dedupe.py — dedupe the eBooks pile against the Calibre library.
|
||||||
|
|
||||||
|
2026-11-04 campaign, ebook side. The Calibre library (/mnt/entropy/CalibreBooks,
|
||||||
|
6,289 books) is DB-owned and healthy; /mnt/entropy/eBooks is a raw pile whose
|
||||||
|
best books already live in the library, several of them twice in the pile.
|
||||||
|
Audiobooks' orphaned ebooks/comics land in the pile first (media-ab-reorg's
|
||||||
|
intake), so this campaign runs after the audiobook move.
|
||||||
|
|
||||||
|
Inputs
|
||||||
|
--meta ebook-meta.tsv: JSON lines {p, sz, title, authors, ident} for
|
||||||
|
epub (embedded OPF) and mobi/azw3 (Palm header title); the
|
||||||
|
producer is the campaign's opfcensus.py.
|
||||||
|
--enum enum TSV (size, ext, path) for every tree.
|
||||||
|
--calibre calibre-books.tsv: JSON lines [id, title, authors, series, path,
|
||||||
|
pubdate, formats] dumped from metadata.db.
|
||||||
|
--dups full blake2b verdicts (hash, size, path) — only confirmed
|
||||||
|
byte-identities remove a file. Optional: without it, identity
|
||||||
|
rules alone run and every hash-dependent pair stays a KEEP.
|
||||||
|
|
||||||
|
Outputs
|
||||||
|
--plan media-apply plan (--root /mnt/entropy): hash-duplicates of a
|
||||||
|
file already in CalibreBooks -> HOLD; byte-identical files inside
|
||||||
|
the pile keep the best format of one book once; non-book junk
|
||||||
|
(mp4/mp3/images) -> PARK.
|
||||||
|
--ingest one JSON line per surviving BOOK (a set of format files of one
|
||||||
|
identity): {paths, title, authors, source}. The ingest runner
|
||||||
|
feeds these to calibredb inside the calibre-web image.
|
||||||
|
--exceptions every identity pair the file-level evidence could not settle.
|
||||||
|
|
||||||
|
Match law: identity is (core title, first author surname). Calibre's
|
||||||
|
"Dinniman| Matt" and an OPF "Matt Dinniman" share surname "dinniman". Title
|
||||||
|
core drops the leading article and all non-alphanumerics. Identity alone NEVER
|
||||||
|
deletes against the pile — it only deletes when the same identity sits in the
|
||||||
|
Calibre library with the same format (the pile copy adds nothing), or when a
|
||||||
|
byte-hash confirms the pair. Format preference when the pile holds one book in
|
||||||
|
several copies: epub > azw3 > mobi > pdf > fb2 > lit > rtf > txt.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
BOOK_EXT = {"epub", "azw3", "mobi", "pdf", "fb2", "lit", "rtf", "txt", "cbr", "cbz"}
|
||||||
|
FORMAT_RANK = {"epub": 0, "azw3": 1, "mobi": 2, "pdf": 3, "fb2": 4,
|
||||||
|
"cbr": 5, "cbz": 5, "lit": 6, "rtf": 7, "txt": 8}
|
||||||
|
ARTICLE = re.compile(r"^(the|a|an)\s+", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_fold(s):
|
||||||
|
return "".join(c for c in unicodedata.normalize("NFKD", s or "")
|
||||||
|
if not unicodedata.combining(c))
|
||||||
|
|
||||||
|
|
||||||
|
def title_core(title):
|
||||||
|
t = ascii_fold(title).lower()
|
||||||
|
t = ARTICLE.sub("", t)
|
||||||
|
t = re.sub(r"\(.*?\)", " ", t) # (1998), (Unabridged) — noise
|
||||||
|
t = re.sub(r"[^a-z0-9 ]", " ", t)
|
||||||
|
t = re.sub(r"\b(1[5-9]|20)\d\d\b", " ", t) # years
|
||||||
|
return " ".join(t.split())[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def surname(author):
|
||||||
|
a = ascii_fold(author).strip()
|
||||||
|
a = re.split(r"[;,]", a)[0] if "," in a else " ".join(a.split()[-1:])
|
||||||
|
return re.sub(r"[^a-z]", "", a.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def first_author(authors):
|
||||||
|
parts = [p.strip() for p in re.split(r"[;&]", authors or "") if p.strip()]
|
||||||
|
return parts[0] if parts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def stem_title(path):
|
||||||
|
s = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
return ascii_fold(s)
|
||||||
|
|
||||||
|
|
||||||
|
class Campaign:
|
||||||
|
def __init__(self, args):
|
||||||
|
self.args = args
|
||||||
|
self.root = args.root.rstrip("/")
|
||||||
|
self.pile = self.root + "/eBooks"
|
||||||
|
self.lib = self.root + "/CalibreBooks"
|
||||||
|
self.hold = args.hold
|
||||||
|
self.park = args.park
|
||||||
|
self.meta = {} # path -> meta dict
|
||||||
|
self.pile_files = [] # (sz, ext, path)
|
||||||
|
self.cal_by_path = {} # file path under CalibreBooks -> calibre row
|
||||||
|
self.cal_identity = {} # (tcore, surname) -> calibre row
|
||||||
|
self.drop = {} # path -> reason
|
||||||
|
self.exc = defaultdict(list)
|
||||||
|
self.moves = []
|
||||||
|
self.dirs_pile_book = defaultdict(list) # book dir (pile) -> files
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- inputs
|
||||||
|
def load(self):
|
||||||
|
for line in open(self.args.meta):
|
||||||
|
r = json.loads(line)
|
||||||
|
self.meta[r["p"]] = r
|
||||||
|
by_hash = defaultdict(list)
|
||||||
|
if self.args.dups:
|
||||||
|
for line in open(self.args.dups):
|
||||||
|
h, sz, p = line.rstrip("\n").split("\t", 2)
|
||||||
|
by_hash[h].append(p)
|
||||||
|
self.dup_sets = [ps for ps in by_hash.values() if len(ps) > 1]
|
||||||
|
for line in open(self.args.enum):
|
||||||
|
sz, ext, p = line.rstrip("\n").split("\t", 2)
|
||||||
|
ext = ext.lstrip(".").lower()
|
||||||
|
if p.startswith(self.pile + "/") and p not in self.park:
|
||||||
|
self.pile_files.append((int(sz), ext, p))
|
||||||
|
for line in open(self.args.calibre):
|
||||||
|
row = json.loads(line)
|
||||||
|
self.cal_identity[(title_core(row[1]), surname(first_author(row[2])))] = row
|
||||||
|
|
||||||
|
def identity(self, p):
|
||||||
|
m = self.meta.get(p)
|
||||||
|
if m and m.get("title"):
|
||||||
|
t = title_core(m["title"])
|
||||||
|
a = surname(first_author(m.get("authors", "")))
|
||||||
|
if t:
|
||||||
|
return (t, a)
|
||||||
|
return (title_core(stem_title(p)), "")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- rules
|
||||||
|
def run(self):
|
||||||
|
pile_paths = {p for _, _, p in self.pile_files}
|
||||||
|
# R1/R2: confirmed byte-duplicates
|
||||||
|
for ps in self.dup_sets:
|
||||||
|
in_lib = [p for p in ps if p.startswith(self.lib + "/")]
|
||||||
|
in_pile = [p for p in ps if p.startswith(self.pile + "/")]
|
||||||
|
if in_lib:
|
||||||
|
for p in in_pile:
|
||||||
|
self.drop[p] = "hash-duplicate of CalibreBooks file"
|
||||||
|
if len(in_pile) > 1:
|
||||||
|
keep = min(in_pile, key=lambda p: (FORMAT_RANK.get(
|
||||||
|
os.path.splitext(p)[1].lstrip(".").lower(), 9), len(p), p))
|
||||||
|
for p in in_pile:
|
||||||
|
if p != keep:
|
||||||
|
self.drop[p] = "hash-duplicate inside the pile"
|
||||||
|
# R3/R4: identity against the library (same format only deletes)
|
||||||
|
cal_files = defaultdict(set) # identity -> formats in the library
|
||||||
|
for key, row in self.cal_identity.items():
|
||||||
|
cal_files[key] = set(f for f in row[6].split("; ") if f)
|
||||||
|
for sz, ext, p in self.pile_files:
|
||||||
|
if p in self.drop or ext not in BOOK_EXT:
|
||||||
|
continue
|
||||||
|
key = self.identity(p)
|
||||||
|
if key[0] and key in self.cal_identity:
|
||||||
|
if ext in cal_files[key]:
|
||||||
|
self.drop[p] = f"same identity+format already in Calibre ({key[0][:40]})"
|
||||||
|
else:
|
||||||
|
self.exc["pile holds a format the library lacks (add-format on ingest)"].append(
|
||||||
|
f"{p}\t{key[0][:50]}\t{self.cal_identity[key][0]}")
|
||||||
|
# R5: surviving files grouped by (identity, parent-ish book dir)
|
||||||
|
books = defaultdict(list)
|
||||||
|
for sz, ext, p in self.pile_files:
|
||||||
|
if p in self.drop:
|
||||||
|
continue
|
||||||
|
if ext not in BOOK_EXT:
|
||||||
|
self.moves.append((p, os.path.join(self.park, os.path.relpath(p, self.root)),
|
||||||
|
"non-book"))
|
||||||
|
continue
|
||||||
|
m = self.meta.get(p, {})
|
||||||
|
if m.get("err") and ext in ("epub",):
|
||||||
|
self.exc["unreadable container (park for a human)"].append(p)
|
||||||
|
self.moves.append((p, os.path.join(self.park, os.path.relpath(p, self.root)),
|
||||||
|
"broken-book"))
|
||||||
|
continue
|
||||||
|
key = self.identity(p)
|
||||||
|
books[key].append(p)
|
||||||
|
for key, ps in books.items():
|
||||||
|
first = min(ps, key=lambda p: (FORMAT_RANK.get(
|
||||||
|
os.path.splitext(p)[1].lstrip(".").lower(), 9), len(p), p))
|
||||||
|
m = self.meta.get(first, {})
|
||||||
|
self.ingest = {
|
||||||
|
"key": list(key),
|
||||||
|
"paths": sorted(ps),
|
||||||
|
"title": m.get("title") or stem_title(first),
|
||||||
|
"authors": m.get("authors") or "",
|
||||||
|
"ident": m.get("ident") or "",
|
||||||
|
}
|
||||||
|
self.ingest_list.append(self.ingest)
|
||||||
|
for p, why in self.drop.items():
|
||||||
|
self.moves.append((p, os.path.join(self.hold, os.path.relpath(p, self.root)), "dup"))
|
||||||
|
|
||||||
|
def write(self):
|
||||||
|
dirs = {os.path.dirname(d) for _, d, _ in self.moves}
|
||||||
|
all_dirs = set()
|
||||||
|
for d in dirs:
|
||||||
|
while d and d != self.root:
|
||||||
|
all_dirs.add(d)
|
||||||
|
d = os.path.dirname(d)
|
||||||
|
with open(self.args.plan, "w") as fh:
|
||||||
|
fh.write("action\tsrc\tdst\tkind\n")
|
||||||
|
for d in sorted(all_dirs):
|
||||||
|
fh.write(f"mkdir\t\t{d}\tcampaign\n")
|
||||||
|
for src, dst, kind in sorted(self.moves):
|
||||||
|
fh.write(f"move\t{src}\t{dst}\t{kind}\n")
|
||||||
|
with open(self.args.ingest, "w") as fh:
|
||||||
|
for b in self.ingest_list:
|
||||||
|
fh.write(json.dumps(b, ensure_ascii=False) + "\n")
|
||||||
|
with open(self.args.exceptions, "w") as fh:
|
||||||
|
for why in sorted(self.exc):
|
||||||
|
fh.write(f"# {why}\n")
|
||||||
|
for line in sorted(set(self.exc[why])):
|
||||||
|
fh.write(line + "\n")
|
||||||
|
fh.write("\n")
|
||||||
|
ndrop = sum(1 for _, _, k in self.moves if k == "dup")
|
||||||
|
print(f"pile books to ingest: {len(self.ingest_list)} "
|
||||||
|
f"files dropped as duplicates: {ndrop} "
|
||||||
|
f"non-book/broken moved out: {sum(1 for _, _, k in self.moves if k in ('non-book', 'broken-book'))}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--root", default="/mnt/entropy")
|
||||||
|
ap.add_argument("--meta", required=True)
|
||||||
|
ap.add_argument("--enum", required=True)
|
||||||
|
ap.add_argument("--calibre", required=True)
|
||||||
|
ap.add_argument("--dups", default=None)
|
||||||
|
ap.add_argument("--hold", default="/mnt/entropy/.media-campaign/dup-hold-ebooks-20261104")
|
||||||
|
ap.add_argument("--park", default="/mnt/entropy/NeedsProcessing/_from-ebooks-20261104")
|
||||||
|
ap.add_argument("--plan", required=True)
|
||||||
|
ap.add_argument("--ingest", required=True)
|
||||||
|
ap.add_argument("--exceptions", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
c = Campaign(args)
|
||||||
|
c.ingest_list = []
|
||||||
|
c.load()
|
||||||
|
c.run()
|
||||||
|
c.write()
|
||||||
|
|
||||||
|
|
||||||
|
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)}")
|
||||||
Executable
+223
@@ -0,0 +1,223 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trigger and poll Jellyfin's library scan — the step that makes a media move visible.
|
||||||
|
|
||||||
|
media-refresh-jellyfin.py trigger [--force]
|
||||||
|
media-refresh-jellyfin.py status [--after EPOCH]
|
||||||
|
media-refresh-jellyfin.py wait [--after EPOCH] [--interval S] [--require-quiet N]
|
||||||
|
|
||||||
|
`status` answers {"scan": true, "taskState": "Running", "recentMedia": 17}: the
|
||||||
|
Scan Media Library row plus the rows the database has taken in recently; with
|
||||||
|
`--after` (the trigger's epoch — a file mtime is already passed by an earlier scan) it
|
||||||
|
also answers `scannedAfter` and exits non-zero
|
||||||
|
while the last finished scan predates it. `wait` calls the library settled only
|
||||||
|
on the page's law: a sighting of the task Running — or a run that finished while
|
||||||
|
it watched, or a `--after` the task already passes — and THEN `--require-quiet`
|
||||||
|
consecutive reads with no scan and no writes. Both the scan and the metadata
|
||||||
|
fetch must be quiet, and the Running sighting is what stops a watcher from
|
||||||
|
declaring a pre-scan library settled (Dankserver/Jellyfin.md).
|
||||||
|
|
||||||
|
Laws this encodes:
|
||||||
|
* Auth is `Authorization: MediaBrowser Token="…"`. A bare X-Emby-Token answers
|
||||||
|
401 (measured on franksdankcloud 12.4.0), which reads exactly like a dead
|
||||||
|
key. The key comes from a mode-600 file and never rides argv or a URL:
|
||||||
|
/proc/<pid>/cmdline is world-readable.
|
||||||
|
* `POST /Library/Refresh` (204) is the ONLY door that queues work on 12.4, and
|
||||||
|
it walks every library. `POST /Items/{id}/Refresh` answers 204 and queues
|
||||||
|
NOTHING (measured twice, 2026-11-02) — so there is no --library here.
|
||||||
|
* A SECOND accepted POST cancels and restarts the scan already running, so
|
||||||
|
progress looks frozen while you hammer it. `trigger` therefore refuses while
|
||||||
|
the Scan Media Library task is not Idle; --force says you want the restart.
|
||||||
|
* 429 means busy. Waiting is the only response that helps, and it is bounded
|
||||||
|
so a wedged server ends the run with a named failure instead of a loop.
|
||||||
|
* `RefreshStatus` on /Library/VirtualFolders stays Idle through a live scan;
|
||||||
|
never poll it. The task table is the only honest read.
|
||||||
|
|
||||||
|
Defaults are this fleet's layout: http://127.0.0.1:8096, the key at
|
||||||
|
~/.config/secrets/jellyfin, and the database found by trying, in order, $JELLYFIN_DB,
|
||||||
|
~/.local/share/jellyfin/jellyfin.db and the quadlet's config bind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
SCAN_TASK = "Scan Media Library"
|
||||||
|
IDLE = ("Idle",) # Running, Cancelling and Waiting all mean: not settled
|
||||||
|
DB_CANDIDATES = ("~/.local/share/jellyfin/jellyfin.db",
|
||||||
|
"/home/dank/Containers/jellyfin/config/data/jellyfin.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch(iso: str | None) -> float | None:
|
||||||
|
"""Jellyfin's `2026-11-04T18:31:33Z` EndTimeUtc as a unix epoch, or None."""
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(iso, fmt).replace(tzinfo=timezone.utc).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
sys.exit(f"the server sent an unparsable EndTimeUtc: {iso!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def read_key(value: str) -> str:
|
||||||
|
path = pathlib.Path(value).expanduser()
|
||||||
|
try:
|
||||||
|
token = path.read_text().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
sys.exit(f"no Jellyfin API key at {path} ({exc.strerror}); "
|
||||||
|
"that file is the credential this driver authenticates with")
|
||||||
|
if not token:
|
||||||
|
sys.exit(f"Jellyfin API key file {path} is empty")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def request(base: str, token: str, method: str, path: str,
|
||||||
|
timeout: float = 30.0) -> tuple[int, bytes]:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base.rstrip("/") + path, method=method,
|
||||||
|
headers={"Authorization": f'MediaBrowser Token="{token}"'})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read()
|
||||||
|
|
||||||
|
|
||||||
|
def scan_task(base: str, token: str) -> dict:
|
||||||
|
"""The one task row that means something, and when its last run finished."""
|
||||||
|
code, body = request(base, token, "GET", "/ScheduledTasks")
|
||||||
|
if code != 200:
|
||||||
|
sys.exit(f"GET /ScheduledTasks -> HTTP {code}: {body[:200]!r}")
|
||||||
|
for task in json.loads(body):
|
||||||
|
if task.get("Name") == SCAN_TASK:
|
||||||
|
state = task.get("State")
|
||||||
|
return {"state": state, "running": state not in IDLE,
|
||||||
|
"lastScan": _epoch(task.get("EndTimeUtc"))}
|
||||||
|
sys.exit(f"the server does not list a {SCAN_TASK!r} task; refusing to guess")
|
||||||
|
|
||||||
|
|
||||||
|
def find_db(value: str) -> pathlib.Path:
|
||||||
|
wanted = [value] if value != "-" else []
|
||||||
|
wanted += [p for p in (os.environ.get("JELLYFIN_DB"),) if p]
|
||||||
|
wanted += list(DB_CANDIDATES)
|
||||||
|
for candidate in wanted:
|
||||||
|
path = pathlib.Path(candidate).expanduser()
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
sys.exit("no Jellyfin database found; tried: " + ", ".join(wanted))
|
||||||
|
|
||||||
|
|
||||||
|
def recent_media(db: str, window: int) -> int:
|
||||||
|
since = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() - window * 60))
|
||||||
|
con = sqlite3.connect(f"file:{find_db(db)}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
return con.execute(
|
||||||
|
"SELECT COUNT(*) FROM BaseItems WHERE DateModified >= ?", (since,)
|
||||||
|
).fetchone()[0]
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("command", choices=["trigger", "status", "wait"])
|
||||||
|
ap.add_argument("--url", default="http://127.0.0.1:8096")
|
||||||
|
ap.add_argument("--key-file", default="~/.config/secrets/jellyfin")
|
||||||
|
ap.add_argument("--db", default="-", help="jellyfin.db; - searches the known homes")
|
||||||
|
ap.add_argument("--window", type=int, default=5,
|
||||||
|
help="minutes of DB writes that still count as recent")
|
||||||
|
ap.add_argument("--retries", type=int, default=3, help="extra attempts against a 429")
|
||||||
|
ap.add_argument("--retry-sleep", type=float, default=20.0)
|
||||||
|
ap.add_argument("--force", action="store_true",
|
||||||
|
help="trigger even though a scan is already running")
|
||||||
|
ap.add_argument("--interval", type=float, default=12.0, help="wait: seconds between reads")
|
||||||
|
ap.add_argument("--require-quiet", type=int, default=3,
|
||||||
|
help="wait: consecutive quiet reads before the library counts as settled")
|
||||||
|
ap.add_argument("--after", type=float, default=None,
|
||||||
|
help="status/wait: an epoch the scan must have finished after — pass "
|
||||||
|
"the trigger's, not a file mtime an earlier scan already passes")
|
||||||
|
ap.add_argument("--start-window", type=float, default=120.0,
|
||||||
|
help="wait: seconds to allow the scan to start before reporting "
|
||||||
|
"that the 204 queued nothing")
|
||||||
|
ap.add_argument("--timeout", type=float, default=1800.0, help="wait: seconds before giving up")
|
||||||
|
args = ap.parse_args()
|
||||||
|
token = read_key(args.key_file)
|
||||||
|
|
||||||
|
def state() -> dict:
|
||||||
|
task = scan_task(args.url, token)
|
||||||
|
out = {"scan": task["running"], "taskState": task["state"],
|
||||||
|
"lastScan": task["lastScan"],
|
||||||
|
"recentMedia": recent_media(args.db, args.window)}
|
||||||
|
if args.after is not None:
|
||||||
|
out["scannedAfter"] = bool(task["lastScan"] and task["lastScan"] >= args.after)
|
||||||
|
return out
|
||||||
|
|
||||||
|
if args.command == "status":
|
||||||
|
current = state()
|
||||||
|
print(json.dumps(current))
|
||||||
|
if args.after is not None and not current["scannedAfter"]:
|
||||||
|
sys.exit(f"the last scan finished before {args.after:.0f}; "
|
||||||
|
"a refresh is still owed")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "wait":
|
||||||
|
start = time.monotonic()
|
||||||
|
ended_at_entry, quiet, started, first = None, 0, False, True
|
||||||
|
while True:
|
||||||
|
current = state()
|
||||||
|
if first:
|
||||||
|
first, ended_at_entry = False, current["lastScan"]
|
||||||
|
if (current["scan"]
|
||||||
|
or (current["lastScan"] and current["lastScan"] != ended_at_entry)
|
||||||
|
or current.get("scannedAfter")):
|
||||||
|
# The settle law: a Running sighting (or a run that finished while
|
||||||
|
# watching, or a --after the task already passes) must come BEFORE
|
||||||
|
# any claim that the library is quiet.
|
||||||
|
started = True
|
||||||
|
busy = current["scan"] or current["recentMedia"]
|
||||||
|
quiet = quiet + 1 if started and not busy else 0
|
||||||
|
print(json.dumps(current | {"quiet": quiet}), flush=True)
|
||||||
|
if quiet >= args.require_quiet:
|
||||||
|
return
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
if not started and elapsed >= args.start_window:
|
||||||
|
sys.exit(f"{SCAN_TASK} never started: it read {current['taskState']!r} "
|
||||||
|
f"for {elapsed:.0f}s and the database took no writes. Either "
|
||||||
|
"the POST queued nothing — a POST that arrives while a scan "
|
||||||
|
"is already running cancels it, and every later 204 is "
|
||||||
|
"accepted and discarded, so restart the service and trigger "
|
||||||
|
"once — or the scan finished before this watch began, in "
|
||||||
|
"which case ask with --after <journal mtime> instead.")
|
||||||
|
if elapsed >= args.timeout:
|
||||||
|
sys.exit(f"still busy after {args.timeout:.0f}s: {current}")
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
if scan_task(args.url, token)["running"] and not args.force:
|
||||||
|
sys.exit(f"{SCAN_TASK} is already running on {args.url}: a second "
|
||||||
|
"POST /Library/Refresh cancels and restarts it, so the index "
|
||||||
|
"looks frozen while you wait. Poll `wait` instead of triggering "
|
||||||
|
"again (--force overrides).")
|
||||||
|
|
||||||
|
for attempt in range(args.retries + 1):
|
||||||
|
code, body = request(args.url, token, "POST", "/Library/Refresh")
|
||||||
|
if code == 204:
|
||||||
|
print("refresh accepted (204); the scan walks every library")
|
||||||
|
return
|
||||||
|
if code == 429:
|
||||||
|
if attempt < args.retries:
|
||||||
|
time.sleep(args.retry_sleep)
|
||||||
|
continue
|
||||||
|
sys.exit(f"Jellyfin is busy: {args.retries + 1} attempts all answered "
|
||||||
|
"429. Nothing was queued; run `wait`, then trigger again.")
|
||||||
|
sys.exit(f"POST /Library/Refresh -> HTTP {code}: {body[:200]!r}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Trigger and poll ONE Plex library section — the step that makes a media move visible.
|
||||||
|
#
|
||||||
|
# media-refresh-plex.sh trigger --library KEY
|
||||||
|
# media-refresh-plex.sh status --library KEY [--after EPOCH]
|
||||||
|
#
|
||||||
|
# One section only: refreshing all five costs a full walk of each and buys
|
||||||
|
# nothing for a rename that touched one tree. And a rename on the array is
|
||||||
|
# invisible until the section is rescanned — the server is bound at the SSD
|
||||||
|
# metadata view, so an array-side move is not an event its watcher ever sees.
|
||||||
|
#
|
||||||
|
# Two host realities shape this script:
|
||||||
|
# * PMS's HTTPD closes without a single byte on a request that arrives from
|
||||||
|
# another netns (the host and the 10.89.x.x container address both read as
|
||||||
|
# "empty reply"), and it serves TLS to remote clients. So the request rides
|
||||||
|
# plain http://127.0.0.1:32400 INSIDE the container, shipped in as a file —
|
||||||
|
# a three-layer ssh/podman/sh quoting chain only produces parse errors.
|
||||||
|
# * The token is `PlexOnlineToken` in the container's Preferences.xml (not
|
||||||
|
# PlexHTTPToken, which a claimed server may not carry). It is read inside
|
||||||
|
# the container and used from a curl config there: never argv (any process
|
||||||
|
# on the host reads /proc/<pid>/cmdline), never a URL (proxies log URLs).
|
||||||
|
#
|
||||||
|
# Progress needs no token and no container: `section_locations.scanned_at` in
|
||||||
|
# the library database is the same epoch the API prints as `scannedAt`
|
||||||
|
# (verified 2026-11-04), so `status` reads the database and compares it against
|
||||||
|
# --after — which makes it a census of the last RECORDED scan, never a waiter: PMS
|
||||||
|
# stamps that epoch when it accepts the request, so the door answers `settled` in the
|
||||||
|
# same second its own `trigger` ran (measured 2026-11-04; no epoch survives it).
|
||||||
|
# `trigger` answering 000 means PMS is still warming — retry, never
|
||||||
|
# restart the service over it. Proof that a rename actually landed is
|
||||||
|
# scripts/media-verify.py replaying the journal; this script only moves the
|
||||||
|
# scanner along.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONTAINER=plex
|
||||||
|
URL="http://127.0.0.1:32400"
|
||||||
|
PREFS='/config/Library/Application Support/Plex Media Server/Preferences.xml'
|
||||||
|
DB="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
|
||||||
|
SECTION=""
|
||||||
|
AFTER=""
|
||||||
|
TRIES=6
|
||||||
|
WARM_SLEEP=10
|
||||||
|
|
||||||
|
die() { echo "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
CMD="${1:-}"; shift || true
|
||||||
|
case "$CMD" in
|
||||||
|
trigger|status) ;;
|
||||||
|
*) die "usage: $(basename "$0") trigger|status --library KEY [--after EPOCH] [--container NAME] [--db PATH]" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--library) SECTION="${2:-}"; shift 2 ;;
|
||||||
|
--after) AFTER="${2:-}"; shift 2 ;;
|
||||||
|
--tries) TRIES="${2:-}"; shift 2 ;;
|
||||||
|
--warm-sleep) WARM_SLEEP="${2:-}"; shift 2 ;;
|
||||||
|
--container) CONTAINER="${2:-}"; shift 2 ;;
|
||||||
|
--url) URL="${2:-}"; shift 2 ;;
|
||||||
|
--prefs) PREFS="${2:-}"; shift 2 ;;
|
||||||
|
--db) DB="${2:-}"; shift 2 ;;
|
||||||
|
*) die "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$SECTION" ] || die "--library is required (the section id from GET /library/sections; Movies is 1)"
|
||||||
|
command -v python3 >/dev/null || die "python3 is required to read the library database"
|
||||||
|
|
||||||
|
if [ "$CMD" = "status" ]; then
|
||||||
|
[ -r "$DB" ] || die "no readable Plex library database at $DB"
|
||||||
|
python3 - "$DB" "$SECTION" "$AFTER" <<'PY'
|
||||||
|
import datetime, sqlite3, sys
|
||||||
|
|
||||||
|
db, section, after = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
||||||
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT root_path, available, scanned_at FROM section_locations "
|
||||||
|
"WHERE library_section_id = ? ORDER BY id", (section,)).fetchall()
|
||||||
|
if not rows:
|
||||||
|
sys.exit(f"section {section} has no location in {db}; is the id right?")
|
||||||
|
worst = min(r[2] or 0 for r in rows)
|
||||||
|
for root, available, at in rows:
|
||||||
|
iso = (datetime.datetime.fromtimestamp(at, datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
if at else "never")
|
||||||
|
print(f" available={available} scanned_at={iso} {root}")
|
||||||
|
print(f"section={section} scanned_at={worst}")
|
||||||
|
if after:
|
||||||
|
if worst < int(after):
|
||||||
|
sys.exit(f"section {section} was last scanned before {after}; a refresh is still owed")
|
||||||
|
PY
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v podman >/dev/null || die "podman is required to reach PMS inside its container"
|
||||||
|
|
||||||
|
INNER="$(mktemp -t pxscan.XXXXXX)"
|
||||||
|
trap 'rm -f "$INNER"' EXIT
|
||||||
|
cat > "$INNER" <<'EOS'
|
||||||
|
# Runs INSIDE the plex container: $1 section key, $2 base URL, $3 Preferences.xml.
|
||||||
|
key="$1"; url="$2"; prefs="$3"
|
||||||
|
[ -r "$prefs" ] || { echo "no readable $prefs inside the container" >&2; exit 3; }
|
||||||
|
T=$(sed -n 's/.*PlexOnlineToken="\([^"]*\)".*/\1/p' "$prefs" | head -1)
|
||||||
|
[ -n "$T" ] || { echo "no PlexOnlineToken in $prefs" >&2; exit 3; }
|
||||||
|
cfg=$(mktemp)
|
||||||
|
( umask 077
|
||||||
|
{ printf 'header = "X-Plex-Token: %s"\n' "$T"
|
||||||
|
printf 'silent\nshow-error\nrequest = "POST"\n'
|
||||||
|
printf 'url = "%s/library/sections/%s/refresh"\n' "$url" "$key"; } > "$cfg" )
|
||||||
|
code=$(curl -K "$cfg" -o /dev/null -w '%{http_code}' || true)
|
||||||
|
shred -u "$cfg" 2>/dev/null || rm -f "$cfg"
|
||||||
|
[ "$code" = "401" ] && { echo "section $key: HTTP 401 — the token in $prefs is not accepted" >&2; exit 4; }
|
||||||
|
echo "$code"
|
||||||
|
EOS
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
while :; do
|
||||||
|
podman cp "$INNER" "$CONTAINER:/tmp/media-refresh-plex.sh" \
|
||||||
|
|| die "could not copy the runner into container $CONTAINER"
|
||||||
|
code="$(podman exec "$CONTAINER" sh /tmp/media-refresh-plex.sh "$SECTION" "$URL" "$PREFS" || true)"
|
||||||
|
podman exec "$CONTAINER" rm -f /tmp/media-refresh-plex.sh >/dev/null 2>&1 || true
|
||||||
|
case "$code" in
|
||||||
|
204|200) echo "refresh accepted ($code) for section $SECTION"; exit 0 ;;
|
||||||
|
000)
|
||||||
|
[ "$attempt" -ge "$TRIES" ] && die "PMS never answered after $TRIES attempts (000); it may still be warming — do not restart the service over it"
|
||||||
|
echo "section $SECTION: no answer yet ($attempt/$TRIES), PMS is warming" >&2 ;;
|
||||||
|
*) die "refresh of section $SECTION failed: HTTP ${code:-curl-error}" ;;
|
||||||
|
esac
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep "$WARM_SLEEP"
|
||||||
|
done
|
||||||
@@ -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
+1264
File diff suppressed because it is too large.
Load diff
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user