Compare commits
44 commits
v6-stable-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bfe230eac | |||
| d1761b0d18 | |||
| 3b67ada1a8 | |||
| 034dbe68c0 | |||
| 675e6ab1ec | |||
| 4750a2c4cc | |||
| c8a1305da4 | |||
| c391447a9f | |||
| 4ab8c277da | |||
| 690ea117c3 | |||
| 6e336d1798 | |||
| 93b9c9d533 | |||
| 9f3483a87c | |||
| 0122de7041 | |||
| 4f0d34fc93 | |||
| e686cc07e0 | |||
| a6ce8451fa | |||
| 7eb5f346fd | |||
| 5b80cfd095 | |||
| fcac178882 | |||
| 22f87d9075 | |||
| c10a3987a7 | |||
| 54997e54a1 | |||
| 3079f5009b | |||
| cb9d5db1ce | |||
| a30edcfa2f | |||
| 520f0fbee3 | |||
| 508fc42a1e | |||
| 24a9497e7d | |||
| c6ec208520 | |||
| fba9a5bfeb | |||
| eb71cf6beb | |||
| d9d6bdba64 | |||
| 755088e7fc | |||
| 7ce1539ea7 | |||
| b3ead71b7e | |||
| 43f55643be | |||
| 23520df2df | |||
| fedf3388b8 | |||
| 1ed55152b7 | |||
| 4f13db63f9 | |||
| 452ce68d7a | |||
| e9d209da73 | |||
| 92e2426734 |
75 changed files with 10659 additions and 53 deletions
229
.forgejo/workflows/secret-scan.yml
Normal file
229
.forgejo/workflows/secret-scan.yml
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# forgejo-actions-secret-scan.yml
|
||||
#
|
||||
# Drop into each repo at: .forgejo/workflows/secret-scan.yml
|
||||
# (Forgejo Actions reads .forgejo/workflows/ natively; .github/workflows/
|
||||
# also works as fallback if a repo has both. Prefer .forgejo/.)
|
||||
#
|
||||
# Layer-2 (CI) of the audit cadence — runs on every push + on pull-request.
|
||||
# Two scanners (gitleaks + detect-secrets) for belt-and-braces coverage.
|
||||
# On hit: opens a Forgejo Issue in this repo (assigned to operator)
|
||||
# with redacted preview, then fails the workflow so any auto-merge stops.
|
||||
#
|
||||
# Required repo secrets:
|
||||
# FORGEJO_TOKEN — PAT with scope `issue:write` for THIS repo only.
|
||||
# Bot account preferred (obsidian-ai), not operator's PAT.
|
||||
#
|
||||
# Runner label: nullstone (the existing self-hosted runner per memory).
|
||||
# If runner is offline / privileged-runner-design rejects this,
|
||||
# fall back to label `docker` and use a vanilla container runner.
|
||||
|
||||
name: secret-scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
# Don't run twice on the same SHA.
|
||||
concurrency:
|
||||
group: secret-scan-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
gitleaks:
|
||||
name: gitleaks (HEAD + history)
|
||||
runs-on: nullstone
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout (full history for --log-opts=all)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install gitleaks
|
||||
run: |
|
||||
set -eu
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
curl -sSL -o /tmp/gitleaks.tgz \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz"
|
||||
mkdir -p /tmp/gl && tar -xzf /tmp/gitleaks.tgz -C /tmp/gl
|
||||
sudo install -m 0755 /tmp/gl/gitleaks /usr/local/bin/gitleaks
|
||||
fi
|
||||
gitleaks version
|
||||
|
||||
- name: Pull s8n-stack ruleset
|
||||
run: |
|
||||
# Operator-tuned ruleset lives in s8n/security-vault.
|
||||
# If the repo is offline, fall back to gitleaks defaults.
|
||||
set -eu
|
||||
if curl -sSL -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-o .gitleaks.toml \
|
||||
"https://git.s8n.ru/s8n/security-vault/raw/branch/main/prevention/.gitleaks.toml"; then
|
||||
echo "loaded operator-tuned ruleset"
|
||||
else
|
||||
echo "fallback to gitleaks defaults" >&2
|
||||
rm -f .gitleaks.toml
|
||||
fi
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
|
||||
- name: Scan HEAD (staged + uncommitted only takes commits)
|
||||
id: gl-head
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p .scan
|
||||
gitleaks detect --source . \
|
||||
--no-banner --redact \
|
||||
${GITLEAKS_CONFIG_FLAG} \
|
||||
--report-format json \
|
||||
--report-path .scan/gitleaks-head.json \
|
||||
--exit-code 0
|
||||
# Count findings:
|
||||
n=$(jq 'length' .scan/gitleaks-head.json 2>/dev/null || echo 0)
|
||||
echo "head_count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "gitleaks HEAD findings: $n"
|
||||
env:
|
||||
GITLEAKS_CONFIG_FLAG: ${{ hashFiles('.gitleaks.toml') != '' && '--config .gitleaks.toml' || '' }}
|
||||
|
||||
- name: Scan history (--log-opts=--all)
|
||||
id: gl-hist
|
||||
run: |
|
||||
set -eu
|
||||
gitleaks detect --source . \
|
||||
--no-banner --redact \
|
||||
${GITLEAKS_CONFIG_FLAG} \
|
||||
--log-opts="--all" \
|
||||
--report-format json \
|
||||
--report-path .scan/gitleaks-history.json \
|
||||
--exit-code 0
|
||||
n=$(jq 'length' .scan/gitleaks-history.json 2>/dev/null || echo 0)
|
||||
echo "history_count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "gitleaks history findings: $n"
|
||||
env:
|
||||
GITLEAKS_CONFIG_FLAG: ${{ hashFiles('.gitleaks.toml') != '' && '--config .gitleaks.toml' || '' }}
|
||||
|
||||
- name: Upload gitleaks reports (artefact)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gitleaks-reports
|
||||
path: .scan/
|
||||
retention-days: 30
|
||||
|
||||
- name: Open Forgejo issue on hit (gitleaks)
|
||||
if: steps.gl-head.outputs.head_count != '0' || steps.gl-hist.outputs.history_count != '0'
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
HEAD_COUNT: ${{ steps.gl-head.outputs.head_count }}
|
||||
HIST_COUNT: ${{ steps.gl-hist.outputs.history_count }}
|
||||
run: |
|
||||
set -eu
|
||||
# Build a redacted preview (rule-id + file + line, no values).
|
||||
preview="$(jq -r '.[] | "- rule:" + .RuleID + " file:" + .File + " line:" + (.StartLine|tostring) + " commit:" + (.Commit // "HEAD")' .scan/gitleaks-head.json .scan/gitleaks-history.json | head -50)"
|
||||
body=$(jq -nR --arg ref "$REF" --arg sha "$SHA" --arg hc "$HEAD_COUNT" --arg histc "$HIST_COUNT" --arg prev "$preview" \
|
||||
'{
|
||||
title: ("[secret-scan] gitleaks hit on " + $ref),
|
||||
body: ("**Automated secret-scan hit.**\n\nRef: `" + $ref + "`\nSHA: `" + $sha + "`\nHEAD findings: " + $hc + "\nHistory findings: " + $histc + "\n\n## Redacted preview\n\n```\n" + $prev + "\n```\n\nFull report: workflow run artefacts (gitleaks-reports).\n\n## Triage\n\n1. False-positive? Add a `.gitleaksignore` entry with justifying comment + close.\n2. True-positive? Trigger incident response per `rules/incident-response-rules.md`. Rotate the affected credential. Then redact + history-rewrite.\n\n/cc @s8n"),
|
||||
labels: ["security","secret-scan"]
|
||||
}')
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"https://git.s8n.ru/api/v1/repos/${REPO}/issues" | jq '.html_url'
|
||||
|
||||
- name: Fail workflow on hit
|
||||
if: steps.gl-head.outputs.head_count != '0' || steps.gl-hist.outputs.history_count != '0'
|
||||
run: |
|
||||
echo "::error::gitleaks found secrets — see opened issue + workflow artefact"
|
||||
exit 1
|
||||
|
||||
detect-secrets:
|
||||
name: detect-secrets (entropy + cross-tool)
|
||||
runs-on: nullstone
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install detect-secrets
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install --user --upgrade pip detect-secrets
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Scan
|
||||
id: ds
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p .scan
|
||||
detect-secrets scan --all-files \
|
||||
--exclude-files '(^|/)(node_modules|venv|\.venv|dist|build|target|out|coverage|\.terraform)/' \
|
||||
--exclude-files '(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|go\.sum)$' \
|
||||
> .scan/detect-secrets.json
|
||||
# Count findings:
|
||||
n=$(jq '.results | to_entries | map(.value | length) | add // 0' .scan/detect-secrets.json)
|
||||
echo "count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "detect-secrets findings: $n"
|
||||
|
||||
- name: Upload detect-secrets report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: detect-secrets-report
|
||||
path: .scan/detect-secrets.json
|
||||
retention-days: 30
|
||||
|
||||
- name: Open Forgejo issue on hit (detect-secrets)
|
||||
if: steps.ds.outputs.count != '0'
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
COUNT: ${{ steps.ds.outputs.count }}
|
||||
run: |
|
||||
set -eu
|
||||
preview="$(jq -r '.results | to_entries[] | .key as $f | .value[] | "- " + $f + ":" + (.line_number|tostring) + " type:" + .type' .scan/detect-secrets.json | head -50)"
|
||||
body=$(jq -nR --arg ref "$REF" --arg c "$COUNT" --arg prev "$preview" \
|
||||
'{
|
||||
title: ("[secret-scan] detect-secrets hit on " + $ref),
|
||||
body: ("**Automated secret-scan hit (detect-secrets).**\n\nRef: `" + $ref + "`\nFindings: " + $c + "\n\n## Redacted preview (file:line type — no values)\n\n```\n" + $prev + "\n```\n\nFull report: workflow run artefacts (detect-secrets-report).\n\n## Triage\n\n1. False-positive? Run locally `detect-secrets audit .scan/detect-secrets.json` and commit the audited baseline.\n2. True-positive? Trigger incident response per `rules/incident-response-rules.md`.\n\n/cc @s8n"),
|
||||
labels: ["security","secret-scan"]
|
||||
}')
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"https://git.s8n.ru/api/v1/repos/${REPO}/issues" | jq '.html_url'
|
||||
|
||||
- name: Fail workflow on hit
|
||||
if: steps.ds.outputs.count != '0'
|
||||
run: |
|
||||
echo "::error::detect-secrets found candidates — see opened issue + workflow artefact"
|
||||
exit 1
|
||||
|
||||
summary:
|
||||
name: summary
|
||||
needs: [gitleaks, detect-secrets]
|
||||
if: always()
|
||||
runs-on: nullstone
|
||||
steps:
|
||||
- name: Outcome
|
||||
run: |
|
||||
echo "secret-scan complete."
|
||||
echo " gitleaks: ${{ needs.gitleaks.result }}"
|
||||
echo " detect-secrets: ${{ needs['detect-secrets'].result }}"
|
||||
9
.gitleaksignore
Normal file
9
.gitleaksignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Gitleaks allowlist — false-positive fingerprints with justification.
|
||||
# Each entry: <relative-path>:<rule-id>:<line>
|
||||
# Justify why each entry is safe to ignore.
|
||||
|
||||
# LAN IP (RFC1918) for nullstone in a Pi-hole local-DNS-pin description.
|
||||
# Same IP appears openly in docs/00-overview.md (Topology table), 21-*,
|
||||
# 22-*, etc. — internal LAN only, never routed publicly. Rule itself
|
||||
# is tagged low-confidence and explicitly suggests allowlisting docs.
|
||||
docs/32-dev-container-wipe-2026-05-11.md:lan-ip-rfc1918:71
|
||||
|
|
@ -108,7 +108,7 @@ ElegantFin imports from `cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/...` —
|
|||
- **Library**: TV Shows → `Futurama (1999)`, S01–S04, **72 episodes + 9 featurettes**, English audio, 1080p HEVC, locked to TMDB 615 / TVDB 73871 / IMDb tt0149460. Polish set deleted 2026-05-08.
|
||||
- **Disk**: nullstone /home 109G free
|
||||
- **Theme**: ElegantFin v25.12.31
|
||||
- **Plugins**: OpenSubtitles v20 (creds pending — see [docs/03](docs/03-subtitles.md))
|
||||
- **Plugins**: OpenSubtitles v20 (creds set, 20 dl/day free tier — see [docs/03](docs/03-subtitles.md))
|
||||
- **Users**: `s8n` (admin), `USER-F` (non-admin, password `123`, change recommended)
|
||||
- **Home layout (per-user, applied to both)**: resume / resumeaudio / nextup / latestmedia (My Media tile row dropped)
|
||||
|
||||
|
|
|
|||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — ARRFLIX
|
||||
|
||||
Last revised: **2026-05-08**
|
||||
Last revised: **2026-05-11**
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -24,10 +24,9 @@ Last revised: **2026-05-08**
|
|||
|
||||
| # | Item | Effort | Blocker |
|
||||
|---|---|---|---|
|
||||
| H1 | OpenSubtitles credentials (auth fixes log spam too — doc 13 win 2) | S | **owner signs up at opensubtitles.com** |
|
||||
| H2 | GPU transcode (nvidia driver kernel module + container toolkit + SecureBoot signing) | L | **owner sudo + reboot** |
|
||||
| H3 | Apply `bin/force-english-all-users.sh` (German Play button breaks UX for non-English browsers) | S | none — owner runs |
|
||||
| H4 | Backup `/home/docker/jellyfin/config/` off-host (no automated backup yet) | M | strategy decision |
|
||||
| H1 | GPU transcode (nvidia driver kernel module + container toolkit + SecureBoot signing) | L | **owner sudo + reboot** |
|
||||
| H2 | Backup `/home/docker/jellyfin/config/` off-host (no automated backup yet) | M | strategy decision |
|
||||
| H3 | Library AV1 sweep + Sonarr/Radarr penalty (kills jellyfin#15646 future) | M | post-doc-26 |
|
||||
|
||||
## 🟨 Open — Medium value
|
||||
|
||||
|
|
@ -56,7 +55,6 @@ Last revised: **2026-05-08**
|
|||
|
||||
| Item | Blocker | Action owner |
|
||||
|---|---|---|
|
||||
| OpenSubtitles auth | account signup at .com | **s8n** |
|
||||
| Nvidia GPU | sudo + reboot decision | **s8n** |
|
||||
| WAN public access | home router port-forward 80/443 → 192.168.0.100 | **s8n** |
|
||||
|
||||
|
|
@ -87,7 +85,7 @@ Last revised: **2026-05-08**
|
|||
- ✅ Detail-page backdrop full-bleed gradient fix (was 17vw black band; now Netflix-style)
|
||||
|
||||
### UI hides + tweaks (CSS in CustomCss)
|
||||
- ✅ Cast & Crew + USER-F Stars sections (`#castCollapsible, #USER-FCastCollapsible`)
|
||||
- ✅ Cast & Crew + Guest Stars sections (`#castCollapsible, #guestCastCollapsible`)
|
||||
- ✅ Quick Connect button + server-side disable (`.btnQuick`, `QuickConnectAvailable=false`)
|
||||
- ✅ Settings drawer link v2 (`a.btnSettings, [data-itemid="settings"]` — verified on dev with headless A/B before swap)
|
||||
- ✅ Header icons: SyncPlay group, Cast, User menu (`.headerSyncButton`, `.headerCastButton`, `.headerUserButton`)
|
||||
|
|
@ -111,7 +109,7 @@ Last revised: **2026-05-08**
|
|||
- ✅ Polish set replaced with English; libraries flipped `pl/PL` → `en/US`
|
||||
|
||||
### Users + access
|
||||
- ✅ 9 users (`s8n` admin, `5`, `USER-D`, `USER-B`, `USER-F`, `USER-G`, `USER-A`, `USER-E`, `USER-C`)
|
||||
- ✅ 9 users (`s8n` admin, `5`, `64bitpotato`, `aloy`, `guest`, `house`, `marco`, `pet`, `yummyhunny`)
|
||||
- ✅ All non-admin policies: `IsAdministrator=false`, `EnableContentDeletion=false`, `EnableUserPreferenceAccess=false`, `LoginAttemptsBeforeLockout=5`
|
||||
- ✅ Wrapper `bin/add-jellyfin-user.sh` — single-call canonical user creation (4-step pipeline: create + home layout + lang prefs + restricted policy)
|
||||
- ✅ Home layout per-user: resume → resumeaudio → nextup → latestmedia (My Media tile row dropped)
|
||||
|
|
@ -121,7 +119,7 @@ Last revised: **2026-05-08**
|
|||
- ✅ Repo rename: `jellyfin-stack` → `NASFLIX` → **`ARRFLIX`** at `git.s8n.ru/s8n/ARRFLIX`
|
||||
- ✅ Pi-hole local DNS for `arrflix.s8n.ru` + `dev.arrflix.s8n.ru`
|
||||
- ✅ LE certs via Gandi DNS-01 for both prod + dev
|
||||
- ✅ WAN window: Gandi public A record `arrflix.s8n.ru → 82.31.156.86`, no-USER-F middleware dropped, lockout=5 baked in (router port-forward pending)
|
||||
- ✅ WAN window: Gandi public A record `arrflix.s8n.ru → 82.31.156.86`, no-guest middleware dropped, lockout=5 baked in (router port-forward pending)
|
||||
- ✅ Dev instance: `dev.arrflix.s8n.ru`, isolated config, shared `/home/user/media:/media:ro` mount with prod (read-only), 7 mirror users + s8n-dev admin
|
||||
- ✅ Snapshot tag `snapshot-2026-05-08-pre-elegantfin` for one-command rollback
|
||||
|
||||
|
|
|
|||
73
bin/fix-home-db.sh
Executable file
73
bin/fix-home-db.sh
Executable file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env bash
|
||||
# Direct SQLite fix for Jellyfin 10.10.3 home-screen sections.
|
||||
#
|
||||
# Why this exists:
|
||||
# The REST endpoint `POST /DisplayPreferences/usersettings?client=Jellyfin Web`
|
||||
# updates `DisplayPreferences.CustomPrefs` but does NOT insert into the
|
||||
# `HomeSection` table for that client (it only inserts when called with
|
||||
# client="emby"). The web client reads from `HomeSection` rows on the
|
||||
# `Jellyfin Web` DisplayPreferences row, so the legacy POST has no
|
||||
# visible effect until those rows exist.
|
||||
#
|
||||
# What this script does (idempotent):
|
||||
# 1. Insert a `Jellyfin Web` DisplayPreferences row for any user missing one.
|
||||
# 2. Seed canonical home layout [Resume, LatestMedia, None*8] for every
|
||||
# DisplayPreferences row that has zero HomeSection rows.
|
||||
# 3. Replace any Type=7 (NextUp) with Type=0 (None) across the table.
|
||||
#
|
||||
# Type integers (Jellyfin.Data.Enums.HomeSectionType):
|
||||
# 0=None, 1=SmallLibraryTiles, 2=LibraryButtons, 3=ActiveRecordings,
|
||||
# 4=Resume, 5=ResumeAudio, 6=LatestMedia, 7=NextUp, 8=LiveTv, 9=ResumeBook
|
||||
#
|
||||
# Container must be stopped during write to avoid corrupting the EF Core
|
||||
# WAL. After write, restart the container.
|
||||
#
|
||||
# Usage:
|
||||
# docker stop jellyfin
|
||||
# DB=/home/docker/jellyfin/config/data/jellyfin.db bin/fix-home-db.sh
|
||||
# docker start jellyfin
|
||||
set -euo pipefail
|
||||
|
||||
DB="${DB:-/home/docker/jellyfin/config/data/jellyfin.db}"
|
||||
[ -f "$DB" ] || { echo "DB not found at $DB"; exit 1; }
|
||||
cp -n "$DB" "$DB.bak.$(date +%s)"
|
||||
|
||||
sqlite3 "$DB" <<'SQL'
|
||||
.bail on
|
||||
BEGIN;
|
||||
|
||||
-- 1) Create Jellyfin Web DP row for users missing one.
|
||||
INSERT INTO DisplayPreferences (UserId, Client, ShowSidebar, ShowBackdrop, ScrollDirection,
|
||||
SkipForwardLength, SkipBackwardLength, ChromecastVersion,
|
||||
EnableNextVideoInfoOverlay, ItemId)
|
||||
SELECT u.Id, 'Jellyfin Web', 0, 1, 0, 30000, 10000, 0, 0, '00000000-0000-0000-0000-000000000000'
|
||||
FROM Users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM DisplayPreferences d
|
||||
WHERE UPPER(d.UserId) = UPPER(u.Id) AND d.Client = 'Jellyfin Web'
|
||||
);
|
||||
|
||||
-- 2) For any DP with zero HomeSection rows, seed [Resume, LatestMedia, None*8].
|
||||
INSERT INTO HomeSection (DisplayPreferencesId, "Order", Type)
|
||||
SELECT d.Id, 0, 4 FROM DisplayPreferences d
|
||||
WHERE NOT EXISTS (SELECT 1 FROM HomeSection h WHERE h.DisplayPreferencesId=d.Id);
|
||||
|
||||
INSERT INTO HomeSection (DisplayPreferencesId, "Order", Type)
|
||||
SELECT d.Id, 1, 6 FROM DisplayPreferences d
|
||||
WHERE (SELECT COUNT(*) FROM HomeSection h WHERE h.DisplayPreferencesId=d.Id) = 1;
|
||||
|
||||
INSERT INTO HomeSection (DisplayPreferencesId, "Order", Type)
|
||||
SELECT d.Id, ord, 0 FROM DisplayPreferences d
|
||||
CROSS JOIN (SELECT 2 AS ord UNION SELECT 3 UNION SELECT 4 UNION SELECT 5
|
||||
UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9)
|
||||
WHERE (SELECT COUNT(*) FROM HomeSection h WHERE h.DisplayPreferencesId=d.Id) = 2;
|
||||
|
||||
-- 3) Replace NextUp (7) with None (0) across all DPs.
|
||||
UPDATE HomeSection SET Type = 0 WHERE Type = 7;
|
||||
|
||||
COMMIT;
|
||||
SQL
|
||||
|
||||
echo "[+] $DB normalized"
|
||||
echo "=== type summary ==="
|
||||
sqlite3 "$DB" "SELECT Type, COUNT(*) FROM HomeSection GROUP BY Type"
|
||||
|
|
@ -33,34 +33,243 @@ wordmark_url = (ASSETS / "arrflix-wordmark.b64-url").read_text(encoding="utf-8")
|
|||
START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */"
|
||||
END = "/* ARRFLIX-MIDDLE-THEME-END */"
|
||||
|
||||
CSS = (
|
||||
"body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}\n"
|
||||
"body.arrflix-themed .skinHeader .headerLeft,body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}\n"
|
||||
"body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}\n"
|
||||
"body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}\n"
|
||||
"body.arrflix-themed .skinHeader .headerHomeButton,body.arrflix-themed .skinHeader .pageTitleWithLogo{display:none!important}\n"
|
||||
"body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}\n"
|
||||
"body.arrflix-themed .skinHeader .headerCastButton,body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}\n"
|
||||
"body.arrflix-themed .headerTabs.sectionTabs{display:none!important}\n"
|
||||
"/* Hide entire header during video playback */\n"
|
||||
"body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader,body.arrflix-video-active .arrflix-headerLogo,body.arrflix-video-active .arrflix-nav{display:none!important}\n"
|
||||
".arrflix-headerLogo{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:120px;height:38px;"
|
||||
"background:center/contain no-repeat url('" + wordmark_url + "');"
|
||||
"z-index:1;display:block;text-indent:-9999px;overflow:hidden}\n"
|
||||
".arrflix-headerLogo:hover{filter:brightness(1.15)}\n"
|
||||
".arrflix-nav{text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:0 .9em;color:#fff!important;text-decoration:none;display:inline-flex;align-items:center;height:100%;font-size:.85em}\n"
|
||||
".arrflix-nav:hover{color:#E50914!important}\n"
|
||||
)
|
||||
CSS = r"""
|
||||
/* ===========================================================================
|
||||
* ARRFLIX MIDDLE-THEME v6 — CSS layer model
|
||||
* ===========================================================================
|
||||
*
|
||||
* STACKING ORDER (low → high) — DO NOT VIOLATE:
|
||||
*
|
||||
* layer 0 <html> — bg #000 (set via JS inline style; see start())
|
||||
* black letterbox bars on video page come from here
|
||||
* layer 1 <body> — bg #000 off-video (L1), transparent on-video (L2)
|
||||
* layer 2 .backgroundContainer — Jellyfin backdrop (poster blur), bg propagated from L1/L2
|
||||
* .skinBody — main app shell
|
||||
* #reactRoot
|
||||
* layer 3 .mainAnimatedPages — page swap container
|
||||
* .pageContainer — current page
|
||||
* layer 4 .skinHeader — top nav (HIDDEN during video — see :not(:has(#loginPage)))
|
||||
* layer 5 .videoPlayerContainer — Jellyfin player wrapper (z:1000 by Jellyfin, fixed inset:0)
|
||||
* └─ video.htmlvideoplayer — the <video> element (z:auto, inherits container stack)
|
||||
* layer 6 .osdControls — Jellyfin OSD bar (scrubber, play/pause, settings)
|
||||
* .videoOsdBottom — bottom controls strip
|
||||
* .upNextDialog — episode-up-next overlay
|
||||
* ALL Jellyfin OSD UI must stay above <video>. Jellyfin sets these
|
||||
* with z-index > 1000 in stock CSS — DO NOT add a higher z-index
|
||||
* to <video> or .videoPlayerContainer or you cover the controls.
|
||||
* layer 7 .dialogContainer — modal dialogs (settings menu, subtitle picker)
|
||||
*
|
||||
* RULE: never z-index <video> or .videoPlayerContainer above 1000.
|
||||
* Stock Jellyfin OSD controls float on top because their CSS sets
|
||||
* z-index in the 1100–2000 range (depending on dialog vs bar).
|
||||
*
|
||||
* BLACK-SCREEN-OVER-VIDEO BUG CLASS — recurring (5+ times in 24h, doc 26/28/30):
|
||||
* ANY rule that paints opaque bg on layer 0–4 ancestors of <video> while
|
||||
* the player is mounted obscures the decoded frames. Two-layer defence:
|
||||
*
|
||||
* L1 (off-video): paint #000 on body+ancestors only when
|
||||
* body lacks .arrflix-video-active class.
|
||||
* L2 (on-video): paint transparent on every known ancestor when
|
||||
* body has .arrflix-video-active. JS toggles this
|
||||
* class via isVideoPage() which checks hash + DOM.
|
||||
*
|
||||
* SPECIFICITY NOTE: L1 (`body.arrflix-themed:not(.arrflix-video-active)`)
|
||||
* and L2 (`body.arrflix-themed.arrflix-video-active`) both score (0,2,1)
|
||||
* on body. Equal specificity → source order decides. L2 listed AFTER L1
|
||||
* in this file → L2 wins when video-active. Good.
|
||||
*
|
||||
* BEFORE ADDING ANY NEW BG-COLOR RULE: ask "does this paint an ancestor of
|
||||
* <video>?" If yes, scope it with `:not(.arrflix-video-active)`. Otherwise
|
||||
* you reopen the black-screen bug. See doc 31 LAYER-MODEL.
|
||||
* ===========================================================================
|
||||
*/
|
||||
|
||||
/* --- HEADER LAYOUT ------------------------------------------------------ */
|
||||
/* Three-column flex: nav-left | logo-center (absolute) | search-right */
|
||||
body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}
|
||||
body.arrflix-themed .skinHeader .headerLeft,
|
||||
body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}
|
||||
body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}
|
||||
body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}
|
||||
|
||||
/* Hide stock Jellyfin header chrome we don't want */
|
||||
body.arrflix-themed .skinHeader .headerHomeButton,
|
||||
body.arrflix-themed .skinHeader .pageTitleWithLogo,
|
||||
body.arrflix-themed .skinHeader .headerBackButton{display:none!important}
|
||||
body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}
|
||||
body.arrflix-themed .skinHeader .headerCastButton,
|
||||
body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}
|
||||
body.arrflix-themed .headerTabs.sectionTabs{display:none!important}
|
||||
|
||||
/* Hide 'My Media' row (.section0) — Continue Watching=section1, Next Up=section5, Recently Added=section6 unaffected */
|
||||
body.arrflix-themed .homePage .homeSectionsContainer .verticalSection.section0{display:none!important}
|
||||
|
||||
/* Header itself disappears during video — :not(:has(#loginPage)) keeps login pre-arrflix-themed render unaffected */
|
||||
body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader,
|
||||
body.arrflix-video-active .arrflix-headerLogo,
|
||||
body.arrflix-video-active .arrflix-nav{display:none!important}
|
||||
|
||||
/* Center wordmark logo — absolute pos, dead-center of headerTop */
|
||||
.arrflix-headerLogo{
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:120px;height:38px;
|
||||
background:center/contain no-repeat url('__WORDMARK_URL__');
|
||||
z-index:1;display:block;text-indent:-9999px;overflow:hidden;
|
||||
}
|
||||
.arrflix-headerLogo:hover{filter:brightness(1.15)}
|
||||
|
||||
/* Movies / Series nav links */
|
||||
.arrflix-nav{
|
||||
text-transform:uppercase;letter-spacing:.08em;font-weight:600;
|
||||
padding:0 .9em;color:#fff!important;text-decoration:none;
|
||||
display:inline-flex;align-items:center;height:100%;font-size:.85em;
|
||||
transition:color .18s ease,text-shadow .18s ease,font-weight .18s ease;
|
||||
}
|
||||
.arrflix-nav:hover{color:#E50914!important}
|
||||
/* Variant E: cinematic glow on active route — toggled by JS on hashchange */
|
||||
.arrflix-nav.active{
|
||||
color:#E50914!important;font-weight:700;
|
||||
text-shadow:0 0 12px rgba(229,9,20,0.55),0 0 24px rgba(229,9,20,0.25);
|
||||
}
|
||||
|
||||
/* --- L1: PURE-BLACK BG (off-video only) -------------------------------- */
|
||||
/* Fires when body does NOT have .arrflix-video-active.
|
||||
* Specificity (0,2,1) on body / (0,3,1) on descendants.
|
||||
* <html> selector has no :not() so html stays #000 always. */
|
||||
html,
|
||||
body.arrflix-themed:not(.arrflix-video-active),
|
||||
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer,
|
||||
body.arrflix-themed:not(.arrflix-video-active) .skinBody,
|
||||
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPage,
|
||||
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPages,
|
||||
body.arrflix-themed:not(.arrflix-video-active) .pageContainer,
|
||||
body.arrflix-themed:not(.arrflix-video-active) #reactRoot{background-color:#000!important}
|
||||
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer.withBackdrop{background-color:rgba(0,0,0,.86)!important}
|
||||
|
||||
/* --- L2: TRANSPARENT ANCESTORS (during video playback) ----------------- */
|
||||
/* Fires when JS sets body.arrflix-video-active. Specificity matched to L1
|
||||
* (0,2,1 on body, 0,3,1 on descendants). L2 wins on source order — listed
|
||||
* AFTER L1 in this file. Without this, opaque ancestor bg paints over <video>.
|
||||
*
|
||||
* NOTE: <html> bg is pinned via JS inline style (start()) so letterbox bars
|
||||
* stay BLACK even though html selector is in L1's gated rule above.
|
||||
*
|
||||
* Targets every known ancestor of <video.htmlvideoplayer>:
|
||||
* body, .backgroundContainer, .skinBody, .mainAnimatedPage(s), .pageContainer,
|
||||
* #reactRoot, .videoPlayerContainer (Jellyfin wrapper, z:1000),
|
||||
* .videoPlayerContainer-onTop, #videoOsdPage + descendants, .libraryPage,
|
||||
* <video> itself.
|
||||
*
|
||||
* DO NOT add z-index to anything in this block. OSD controls (layer 6) sit
|
||||
* above <video> via Jellyfin's stock z-index 1100+. Lifting <video> z-index
|
||||
* obscures controls — see image #12 incident. */
|
||||
body.arrflix-themed.arrflix-video-active,
|
||||
body.arrflix-themed.arrflix-video-active .backgroundContainer,
|
||||
body.arrflix-themed.arrflix-video-active .skinBody,
|
||||
body.arrflix-themed.arrflix-video-active .mainAnimatedPage,
|
||||
body.arrflix-themed.arrflix-video-active .mainAnimatedPages,
|
||||
body.arrflix-themed.arrflix-video-active .pageContainer,
|
||||
body.arrflix-themed.arrflix-video-active #reactRoot,
|
||||
body.arrflix-themed.arrflix-video-active .videoPlayerContainer,
|
||||
body.arrflix-themed.arrflix-video-active .videoPlayerContainer-onTop,
|
||||
body.arrflix-themed.arrflix-video-active #videoOsdPage,
|
||||
body.arrflix-themed.arrflix-video-active #videoOsdPage .pageContainer,
|
||||
body.arrflix-themed.arrflix-video-active #videoOsdPage .mainAnimatedPage,
|
||||
body.arrflix-themed.arrflix-video-active #videoOsdPage .layout-desktop,
|
||||
body.arrflix-themed.arrflix-video-active .libraryPage,
|
||||
body.arrflix-themed.arrflix-video-active video.htmlvideoplayer{
|
||||
background-color:transparent!important;
|
||||
background:transparent!important;
|
||||
background-image:none!important;
|
||||
}
|
||||
|
||||
/* --- ACTION-SHEET SELECTOR (audio/subtitle dropdowns) ----------------- *
|
||||
* Stock Jellyfin theme.css paints `.listItem.selected` and `.focused` in
|
||||
* cyan (#00a4dc) — clashes with Cineplex red. Override to "Hairline ring"
|
||||
* variant: 1px red outline (offset -1px so it sits inside the row) + dark
|
||||
* near-black bg + white text. Architectural, quietly on-brand.
|
||||
*
|
||||
* Targets every Jellyfin picker/dropdown form:
|
||||
* .actionSheet .listItem(.selected|.focused) — modal action sheets (audio/sub)
|
||||
* .selectionList .listItem.selected — legacy selection lists
|
||||
* .dialogContainer .listItem.selected — dialog-scoped selectors
|
||||
*
|
||||
* Future swap: see web-overrides/skins/selector-variant-02-red-underline.css
|
||||
* for the alternative "red underline" design (matches search-input focus).
|
||||
*/
|
||||
body.arrflix-themed .actionSheet .listItem.selected,
|
||||
body.arrflix-themed .actionSheet .listItem-button.selected,
|
||||
body.arrflix-themed .actionSheet .listItem.focused,
|
||||
body.arrflix-themed .selectionList .listItem.selected,
|
||||
body.arrflix-themed .dialogContainer .listItem.selected{
|
||||
outline:1px solid #E50914!important;
|
||||
outline-offset:-1px;
|
||||
background:rgba(15,15,15,.7)!important;
|
||||
color:#fff!important;
|
||||
}
|
||||
|
||||
/* --- SEARCH INPUT (cyan ring → red underline) ------------------------- */
|
||||
/* Stock Jellyfin theme.css:262-272 sets blue focus ring (#00a4dc).
|
||||
* Replace with borderless slab + red bottom border + soft red glow.
|
||||
* Cineplex/Netflix-faithful. */
|
||||
body.arrflix-themed .searchFields .emby-input,
|
||||
body.arrflix-themed input.searchfields-txtSearch,
|
||||
body.arrflix-themed #searchTextInput{
|
||||
background:#141414!important;border:0!important;
|
||||
border-bottom:2px solid transparent!important;border-radius:2px!important;
|
||||
color:#fff!important;padding:.55em .8em!important;
|
||||
transition:border-color .18s ease,box-shadow .18s ease,background-color .18s ease;
|
||||
}
|
||||
body.arrflix-themed .searchFields .emby-input::placeholder,
|
||||
body.arrflix-themed input.searchfields-txtSearch::placeholder,
|
||||
body.arrflix-themed #searchTextInput::placeholder{color:rgba(255,255,255,.4);letter-spacing:.02em}
|
||||
body.arrflix-themed .searchFields .emby-input:hover,
|
||||
body.arrflix-themed input.searchfields-txtSearch:hover,
|
||||
body.arrflix-themed #searchTextInput:hover{background:#1a1a1a!important}
|
||||
body.arrflix-themed .searchFields .emby-input:focus,
|
||||
body.arrflix-themed input.searchfields-txtSearch:focus,
|
||||
body.arrflix-themed #searchTextInput:focus{
|
||||
background:#1a1a1a!important;border:0!important;
|
||||
border-bottom:2px solid #E50914!important;
|
||||
box-shadow:0 1px 0 0 rgba(229,9,20,.35),0 0 14px -2px rgba(229,9,20,.35)!important;
|
||||
outline:none!important;
|
||||
}
|
||||
""".replace("__WORDMARK_URL__", wordmark_url)
|
||||
|
||||
JS = """
|
||||
/* ARRFLIX middle-theme JS shim — runtime DOM mutations + body-class toggles.
|
||||
*
|
||||
* BODY CLASSES we manage:
|
||||
* .arrflix-themed — set when isAuthed() = true. Gates the entire theme.
|
||||
* Removed on logout/login route; CSS rules disable.
|
||||
* .arrflix-video-active — set when isVideoPage() = true. Gates L2 transparency
|
||||
* and hides .skinHeader. Toggled live on hashchange
|
||||
* + every 1.5s tick + on every body-mutation.
|
||||
*
|
||||
* INLINE STYLE on <html>:
|
||||
* We force background-color:#000 via setProperty(...,'important') because
|
||||
* getComputedStyle(html).backgroundColor inexplicably returned rgba(0,0,0,0)
|
||||
* on details/video pages despite 5 stylesheet rules saying #000 !important.
|
||||
* Inline style is the highest specificity short of !important user-agent.
|
||||
* This guarantees the canvas behind any transparent body stays BLACK
|
||||
* (so video-page letterbox bars are black, not browser-default white).
|
||||
*/
|
||||
(function(){
|
||||
function isVideoPage(){
|
||||
/* Returns true if the user is currently on a video-playback page.
|
||||
* Three signals (any one is enough):
|
||||
* 1. URL hash contains '/video' (Jellyfin's video route)
|
||||
* 2. #videoOsdPage element is visible (the OSD page id Jellyfin mounts)
|
||||
* 3. video.htmlvideoplayer (lowercase!) element is visible
|
||||
* NOTE: '.htmlVideoPlayer' (camelCase) does NOT exist in Jellyfin 10.10.
|
||||
* The real class is lowercase 'htmlvideoplayer'.
|
||||
*/
|
||||
try{
|
||||
var h=(location.hash||'').toLowerCase();
|
||||
if (h.indexOf('/video') !== -1) return true;
|
||||
var osd = document.querySelector('#videoOsdPage:not(.hide)');
|
||||
if (osd) return true;
|
||||
var v = document.querySelector('.htmlVideoPlayer:not(.hide), video.htmlvideoplayer:not(.hide)');
|
||||
var v = document.querySelector('video.htmlvideoplayer:not(.hide)');
|
||||
if (v && getComputedStyle(v).display !== 'none') return true;
|
||||
}catch(e){}
|
||||
return false;
|
||||
|
|
@ -106,8 +315,14 @@ JS = """
|
|||
var right=top.querySelector('.headerRight');
|
||||
top.insertBefore(a, right || null);
|
||||
}
|
||||
var hash=(location.hash||'').toLowerCase();
|
||||
var movieMatch=(hash==='#/movies.html'||hash==='#/movies');
|
||||
var seriesMatch=(hash==='#/tv.html'||hash==='#/tv');
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-arrflix-nav=\"movies\"]'),function(n){ n.classList.toggle('active',movieMatch); });
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-arrflix-nav=\"series\"]'),function(n){ n.classList.toggle('active',seriesMatch); });
|
||||
}
|
||||
function start(){
|
||||
try{ document.documentElement.style.setProperty('background-color','#000','important'); }catch(e){}
|
||||
relayoutHeader();
|
||||
try{ new MutationObserver(relayoutHeader).observe(document.body,{childList:true,subtree:true}); }catch(e){}
|
||||
window.addEventListener('hashchange', relayoutHeader);
|
||||
|
|
|
|||
54
bin/revert-next-ep-popup.sh
Executable file
54
bin/revert-next-ep-popup.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env bash
|
||||
# Revert the NEXT-EP-POPUP shim injected into dev's index-dev.html on 2026-05-10.
|
||||
#
|
||||
# What it removes:
|
||||
# /* NEXT-EP-POPUP-BEGIN ... */ ... /* NEXT-EP-POPUP-END */
|
||||
#
|
||||
# Defaults to dev. Pass --prod to remove from prod's index.html instead.
|
||||
# Idempotent: safe to re-run.
|
||||
#
|
||||
# After local edit, redeploy to nullstone via the same nsenter cp trick used
|
||||
# for sub-label-shim revert (see comment at end).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TARGET_DEV="$REPO_ROOT/web-overrides/index-dev.html"
|
||||
TARGET_PROD="$REPO_ROOT/web-overrides/index.html"
|
||||
|
||||
TARGET="$TARGET_DEV"
|
||||
ENV="dev"
|
||||
if [[ "${1:-}" == "--prod" ]]; then
|
||||
TARGET="$TARGET_PROD"
|
||||
ENV="prod"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$TARGET" ]]; then
|
||||
echo "ERROR: $TARGET not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "NEXT-EP-POPUP-BEGIN" "$TARGET"; then
|
||||
echo "shim already absent in $ENV — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp "$TARGET" "$TARGET.bak.$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
sed -i '/NEXT-EP-POPUP-BEGIN/,/NEXT-EP-POPUP-END/d' "$TARGET"
|
||||
|
||||
if grep -q "NEXT-EP-POPUP" "$TARGET"; then
|
||||
echo "ERROR: revert left orphan markers in $TARGET" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "reverted ($ENV). backup at $TARGET.bak.*"
|
||||
echo
|
||||
echo "Now redeploy to nullstone:"
|
||||
if [[ "$ENV" == "dev" ]]; then
|
||||
echo " scp $TARGET user@192.168.0.100:/tmp/index-dev-new.html"
|
||||
echo " ssh user@192.168.0.100 'docker run --rm --privileged --pid=host --userns=host -v /opt:/opt -v /tmp:/tmp alpine nsenter -t 1 -m -u -i -n cp /tmp/index-dev-new.html /opt/docker/jellyfin-dev/web-overrides/index-dev.html'"
|
||||
else
|
||||
echo " scp $TARGET user@192.168.0.100:/tmp/arrflix-index.html"
|
||||
echo " ssh user@192.168.0.100 'docker run --rm --privileged --pid=host --userns=host -v /opt:/opt -v /tmp:/tmp alpine nsenter -t 1 -m -u -i -n cp /tmp/arrflix-index.html /opt/docker/jellyfin/web-overrides/index.html'"
|
||||
fi
|
||||
echo "Then hard-refresh the browser (Ctrl+Shift+R)."
|
||||
37
bin/revert-sub-label-shim.sh
Executable file
37
bin/revert-sub-label-shim.sh
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env bash
|
||||
# Revert the SUB-LABEL-SHIM injected into web-overrides/index.html on 2026-05-10.
|
||||
#
|
||||
# What it removes:
|
||||
# /* SUB-LABEL-SHIM-BEGIN ... */ ... /* SUB-LABEL-SHIM-END */
|
||||
#
|
||||
# Idempotent: safe to re-run.
|
||||
# After reverting, hard-refresh the browser (Ctrl+Shift+R) so the cached
|
||||
# index.html is fetched fresh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TARGET="$REPO_ROOT/web-overrides/index.html"
|
||||
|
||||
if [[ ! -f "$TARGET" ]]; then
|
||||
echo "ERROR: $TARGET not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "SUB-LABEL-SHIM-BEGIN" "$TARGET"; then
|
||||
echo "shim already absent — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp "$TARGET" "$TARGET.bak.$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
# delete from BEGIN marker line through END marker line, inclusive
|
||||
sed -i '/SUB-LABEL-SHIM-BEGIN/,/SUB-LABEL-SHIM-END/d' "$TARGET"
|
||||
|
||||
if grep -q "SUB-LABEL-SHIM" "$TARGET"; then
|
||||
echo "ERROR: revert left orphan markers in $TARGET" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "reverted. backup at $TARGET.bak.*"
|
||||
echo "next: container needs no restart (index.html is bind-mounted); hard-refresh browser."
|
||||
100
bin/set-home-layout.py
Executable file
100
bin/set-home-layout.py
Executable file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Patch Jellyfin home-screen section layout for every user on a given instance.
|
||||
|
||||
Default policy (this script):
|
||||
- Continue Watching (`resume`) ENABLED for every user
|
||||
- Resume Audio (`resumeaudio`) preserved if present
|
||||
- Next Up (`nextup`) DISABLED (replaced with `none`)
|
||||
- Latest Media (`latestmedia`) preserved if present
|
||||
- If a user's layout was empty (factory default), seed slot 0 with `resume`
|
||||
|
||||
Idempotent. Safe to re-run.
|
||||
|
||||
Usage:
|
||||
JF_URL=https://arrflix.s8n.ru \
|
||||
JF_TOKEN=<admin token> \
|
||||
python3 bin/set-home-layout.py
|
||||
|
||||
Token retrieval (admin):
|
||||
docker cp jellyfin:/config/data/jellyfin.db /tmp/jf.db
|
||||
sqlite3 /tmp/jf.db \\
|
||||
'SELECT d.AccessToken FROM Devices d JOIN Users u ON d.UserId=u.Id
|
||||
WHERE u.Username="s8n" ORDER BY d.DateLastActivity DESC LIMIT 1'
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
JF_URL = os.environ.get("JF_URL")
|
||||
JF_TOKEN = os.environ.get("JF_TOKEN")
|
||||
|
||||
if not JF_URL or not JF_TOKEN:
|
||||
sys.exit("set JF_URL and JF_TOKEN env vars")
|
||||
|
||||
# Jellyfin 10.10.3 web client uses 'Jellyfin Web' as the DisplayPreferences
|
||||
# client name. The older 'emby' name is read by legacy SDKs only — writing
|
||||
# only to 'emby' has no effect on the web UI. Patch every per-client doc to
|
||||
# keep all consumers in sync.
|
||||
CLIENTS = ["Jellyfin Web", "emby", "emby-mobile", "emby-web"]
|
||||
|
||||
|
||||
def http(method, url, body=None):
|
||||
req = urllib.request.Request(url, method=method)
|
||||
req.add_header("X-Emby-Token", JF_TOKEN)
|
||||
data = None
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
data = json.dumps(body).encode()
|
||||
with urllib.request.urlopen(req, data=data) as r:
|
||||
text = r.read().decode()
|
||||
return r.status, (json.loads(text) if text else None)
|
||||
|
||||
|
||||
def patch_user_client(user_id, client):
|
||||
q = urllib.parse.urlencode({"userId": user_id, "client": client})
|
||||
url = f"{JF_URL}/DisplayPreferences/usersettings?{q}"
|
||||
_, prefs = http("GET", url)
|
||||
cp = prefs.get("CustomPrefs") or {}
|
||||
|
||||
sections = [cp.get(f"homesection{i}", "none") for i in range(10)]
|
||||
before = list(sections)
|
||||
|
||||
sections = ["none" if s == "nextup" else s for s in sections]
|
||||
if "resume" not in sections:
|
||||
if "none" in sections:
|
||||
sections[sections.index("none")] = "resume"
|
||||
else:
|
||||
sections = ["resume"] + sections[:9]
|
||||
if "latestmedia" not in sections:
|
||||
for i, s in enumerate(sections):
|
||||
if s == "none":
|
||||
sections[i] = "latestmedia"
|
||||
break
|
||||
|
||||
for i, s in enumerate(sections):
|
||||
cp[f"homesection{i}"] = s
|
||||
prefs["CustomPrefs"] = cp
|
||||
|
||||
changed = before != sections
|
||||
if changed:
|
||||
http("POST", url, body=prefs)
|
||||
return changed, before, sections
|
||||
|
||||
|
||||
def main():
|
||||
users = http("GET", f"{JF_URL}/Users")[1]
|
||||
print(f"{JF_URL} — {len(users)} users")
|
||||
for u in users:
|
||||
for client in CLIENTS:
|
||||
changed, before, after = patch_user_client(u["Id"], client)
|
||||
if changed:
|
||||
print(f" [CHANGED] {u['Name']:<14} client={client:<14} {before} -> {after}")
|
||||
else:
|
||||
print(f" [ ok ] {u['Name']:<14} client={client:<14} {after}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -317,7 +317,7 @@ Plugin logs: `docker logs jellyfin 2>&1 | grep -i opensubtitles`.
|
|||
| User `s8n` `SubtitleMode` | `Always` |
|
||||
| User `s8n` `SubtitleLanguagePreference` | `eng` |
|
||||
| User `s8n` `AudioLanguagePreference` | `pol` |
|
||||
| OpenSubtitles **credentials** | **PENDING — user signs up at <https://www.opensubtitles.com>** |
|
||||
| Series refresh to fetch all 44 | **PENDING — after creds entered** |
|
||||
| OpenSubtitles **credentials** | **SET** — user `Caveman5`, `CredentialsInvalid=false` (verified 2026-05-11) |
|
||||
| Series refresh to fetch all 44 | **READY** — trigger via UI or `MetadataRefreshMode=FullRefresh` API call |
|
||||
|
||||
When the user enters creds and runs the series refresh in § 5.2, expect ~20 episodes downloaded the first day (free quota), the rest over the next two days unless upgraded. Sidecar filenames will be `Futurama.s01eXX.pl.eng.srt` next to each `.mkv`.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ action). Effort: **S** ≤ 30 min, **M** half-day, **L** > 1 day.
|
|||
| 01 | Host capacity | **R** | `uptime` load 11.40 / 9.59 / 6.19 on 12 cores; swap 6.8 GiB used / 24 GiB; `/home` 90 % full | Identify swap hog (likely not Jellyfin — only 522 MiB RSS); reclaim space on `/home`; budget media additions against the 40 GiB headroom | M |
|
||||
| 02 | GPU transcode | **R** | `nvidia-smi` fails, no `/dev/nvidia*`, `lsmod` no nvidia mod; `HardwareAccelerationType=none` | Reinstall nvidia driver on nullstone host; once `nvidia-smi` works, add device reservation block to compose and flip `HardwareAccelerationType` to `nvenc` | L |
|
||||
| 03 | Transcode throttling | **R** | `EnableThrottling=false`, `ThrottleDelaySeconds=180`, `MaxMuxingQueueSize=2048`, **two 499 client-cancels** logged (6 439 ms / 2 890 ms) | Enable `EnableThrottling=true` and `EnableSegmentDeletion=true` for CPU-only era — caps wasted ffmpeg CPU after client disconnect | S |
|
||||
| 04 | OpenSubtitles auth | **R** | `Username`/`Password` empty in `Jellyfin.Plugin.OpenSubtitles.xml`; **102** `Error downloading subtitles from Open Subtitles` lines / 6 h | Set creds via UI, OR disable the provider on both libraries (`EnableInternetProviders=false` already; subtitle search still runs). Doc 03-subtitles.md already calls this out as pending | S |
|
||||
| 04 | OpenSubtitles auth | **G** | Creds set `Caveman5`, `CredentialsInvalid=false` (verified 2026-05-11). Spam loop resolved | RESOLVED | — |
|
||||
| 05 | Cache trash budget | **Y** | `EnableSegmentDeletion=false`, `SegmentKeepSeconds=720`; `/cache/transcodes` only 20 K right now (no live stream), but a 4K HEVC→h264 session will fill GiBs and not auto-prune | Enable `EnableSegmentDeletion=true` (default 720 s keep is fine) — pairs with finding 03 | S |
|
||||
| 06 | Backup posture | **R** | `/home/docker/jellyfin/config/` (104 MB) has no off-host rotation; `snapshots/` in repo only holds pre-ElegantFin baseline | Add a weekly `tar.zst` of `/config/` (excluding `log/`, `cache/`) to NAS or git-backed snapshot dir | M |
|
||||
| 07 | Disk pressure | **Y** | `/home` 90 % full, 40 GiB free of 399 GiB; `/home/user/media` only 189 files | Cap on media growth: at current free space + episode bitrate budget user has ~3–4 more series before disk fills | M |
|
||||
|
|
|
|||
|
|
@ -46,10 +46,9 @@ Point-in-time visual status after doc-26 incident. For ongoing roadmap see
|
|||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ HIGH-VALUE OPEN (next session) ─────────────────────────────────┐
|
||||
│ H1 OpenSubtitles creds (owner sign up at .com) │
|
||||
│ H2 GPU transcode (nvidia driver + container toolkit + SecureBoot)│
|
||||
│ H1 GPU transcode (nvidia driver + container toolkit + SecureBoot)│
|
||||
│ → unlocks 4K HDR realtime instead of 0.5x │
|
||||
│ H3 Off-host backup of /home/docker/jellyfin/config │
|
||||
│ H2 Off-host backup of /home/docker/jellyfin/config │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ MEDIUM-VALUE OPEN ──────────────────────────────────────────────┐
|
||||
|
|
|
|||
153
docs/29-jellyfin-10.11-upgrade-and-scyfin-migration.md
Normal file
153
docs/29-jellyfin-10.11-upgrade-and-scyfin-migration.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# 29 — Jellyfin 10.11.8 upgrade + scyfin theme migration (dev)
|
||||
|
||||
Date: 2026-05-11
|
||||
Scope: `jellyfin-dev` (dev.arrflix.s8n.ru) only. Prod still on 10.10.3.
|
||||
Trigger: home-section bug in 10.10.3 — `POST /DisplayPreferences/usersettings?client=Jellyfin%20Web` updated `CustomPrefs` but did NOT insert into the `HomeSection` table. Web UI ignored the layout and rendered factory defaults including Next Up. Verified fixed on 10.11.8 — see § 5.
|
||||
|
||||
---
|
||||
|
||||
## 1. Migration path (executed)
|
||||
|
||||
```
|
||||
10.10.3 → 10.10.7 → snapshot → 10.11.8
|
||||
```
|
||||
|
||||
Direct 10.10.3 → 10.11.x is unsupported. Skipping 10.10.7 is the most common cause of `MigrateLibraryDb` crashes (jellyfin#15027, #15244, #15293, #15504). Both stages take ~10s on our 176MB config.
|
||||
|
||||
### Snapshots kept
|
||||
|
||||
```
|
||||
/home/user/snapshots/jellyfin-dev-pre-1011-upgrade-20260511-033309.tar.zst (137M)
|
||||
/home/user/snapshots/jellyfin-dev-post-10107-20260511-033839.tar.zst (138M)
|
||||
```
|
||||
|
||||
Both produced via privileged Alpine + `tar --zstd` because userns-remap blocks tar from writing back to host bind paths (per `feedback_docker_sudo_bypass.md`).
|
||||
|
||||
### Compose change
|
||||
|
||||
```yaml
|
||||
# /opt/docker/jellyfin-dev/docker-compose.yml
|
||||
- image: jellyfin/jellyfin:10.10.3
|
||||
+ image: jellyfin/jellyfin:10.11.8
|
||||
```
|
||||
|
||||
No volume changes. No env-var changes. Same `user: "1000:1000"`, `userns_mode: "host"`, same index.html bind-mount path (`/jellyfin/jellyfin-web/index.html`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Schema changes observed
|
||||
|
||||
- `library.db` consolidated into `jellyfin.db` (EF Core finalisation). Old DB removed.
|
||||
- New tables: `BaseItems`, `BaseItemImageInfos`, `BaseItemMetadataFields`, `BaseItemProviders`, `BaseItemTrailerTypes`, `ItemValuesMap`, `MediaStreamInfos`, `AttachmentStreamInfos`, `KeyframeData`, `PeopleBaseItemMap`, `Peoples`. (Old `TypedBaseItems`, `mediastreams`, `People` are gone.)
|
||||
- `DisplayPreferences` + `HomeSection` schema unchanged — same columns as 10.10.3.
|
||||
- ffmpeg bumped 7.0.2 → 7.1.3 (better tonemapping).
|
||||
|
||||
Internal migrations applied automatically:
|
||||
```
|
||||
20251009200000_CleanMusicArtist
|
||||
20260206200000_FixLibrarySubtitleDownloadLanguages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Theme switch — Cineplex v1.0.6 → scyfin OLED
|
||||
|
||||
Reasons:
|
||||
- Cineplex pinned to 10.10.x, abandoned for 10.11.
|
||||
- scyfin (https://github.com/loof2736/scyfin) is the only top-tier 10.11.x theme with a dedicated 10.11 branch + recent release (v1.5.3, 2026-03-25) + zero open 10.11 bug reports.
|
||||
- OLED variant gives a true black palette aligned with full-bleed ARRFLIX backdrop goals.
|
||||
|
||||
Imports:
|
||||
```css
|
||||
@import url('https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css');
|
||||
@import url('https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-oled.css');
|
||||
```
|
||||
|
||||
Plus ARRFLIX-specific overrides (accent `#E50914`, force-English Play button, hide Cast & Crew / Quick Connect / Cast / SyncPlay, themed scrollbar). Full CustomCss in `/tmp/dev-branding.json` and applied via:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "X-Emby-Token: $TOK" -H "Content-Type: application/json" \
|
||||
--data-binary @branding.json \
|
||||
https://dev.arrflix.s8n.ru/System/Configuration/branding
|
||||
```
|
||||
|
||||
(POST returned 500 but the write persisted — verified via GET. Likely an EphemeralXmlRepository warning side-effect; non-blocking.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Home-section bug — fixed in 10.11.8
|
||||
|
||||
The 10.10.3 bug: posting CustomCss-style `homesection0…9` keys updated `CustomPrefs` but did NOT insert into the `HomeSection` table. The web client (10.10 + 10.11) reads `HomeSection` rows for `client="Jellyfin Web"`, so the legacy POST was a no-op visually.
|
||||
|
||||
On 10.11.8 the same endpoint accepts a `HomeSections` array in the request body and writes both `CustomPrefs` and `HomeSection` rows atomically. Tested:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "X-Emby-Token: $TOK" -H "Content-Type: application/json" \
|
||||
"https://dev.arrflix.s8n.ru/DisplayPreferences/usersettings?userId=$UID&client=Jellyfin%20Web" \
|
||||
-d '{"Id":"","SortBy":"SortName",...,
|
||||
"CustomPrefs":{"homesection0":"resume","homesection1":"latestmedia", ...},
|
||||
"HomeSections":[{"Order":0,"Type":"Resume"},{"Order":1,"Type":"LatestMedia"},{"Order":2,"Type":"None"}, ...],
|
||||
"Client":"Jellyfin Web"}'
|
||||
```
|
||||
|
||||
→ HTTP 204 + DB shows `Type=4` at slot 0, `Type=6` at slot 1, `Type=0` everywhere else.
|
||||
|
||||
Type integer reference (`Jellyfin.Data.Enums.HomeSectionType`):
|
||||
```
|
||||
0 = None
|
||||
1 = SmallLibraryTiles
|
||||
2 = LibraryButtons
|
||||
3 = ActiveRecordings
|
||||
4 = Resume
|
||||
5 = ResumeAudio
|
||||
6 = LatestMedia
|
||||
7 = NextUp <-- explicitly excluded by ARRFLIX policy
|
||||
8 = LiveTv
|
||||
9 = ResumeBook
|
||||
```
|
||||
|
||||
ARRFLIX policy is now: **slot 0 = Resume, slot 1 = LatestMedia, slot 2-9 = None**. Continue Watching is always on, Next Up never.
|
||||
|
||||
---
|
||||
|
||||
## 5. Plugin compat
|
||||
|
||||
- OpenSubtitles **v20** (current prod) has `targetAbi: 10.9.0.0` — will NOT load on 10.11.x.
|
||||
- v24 with `targetAbi: 10.11.8.0` is required (jellyfin-plugin-opensubtitles#166).
|
||||
- Known regression: server issue #16544 reports SRT save failing on 10.11.7+ — workaround = Bazarr proxy. Re-test on dev before promoting to prod.
|
||||
|
||||
Plugin upgrade command (deferred until we validate the rest):
|
||||
```bash
|
||||
curl -X POST -H "X-Emby-Token: $TOK" \
|
||||
"https://dev.arrflix.s8n.ru/Packages/Installed/Open%20Subtitles?AssemblyGuid=4b9ed42f-5185-48b5-9803-6ff2989014c4&Version=24.0.0.0&RepositoryUrl=https%3A%2F%2Frepo.jellyfin.org%2Ffiles%2Fplugin%2Fmanifest.json"
|
||||
docker restart jellyfin-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Outstanding before promoting dev → prod
|
||||
|
||||
- [ ] Test scyfin OLED rendering across home / detail / playback / settings on dev.
|
||||
- [ ] Verify Continue Watching renders for `test` user (5 resume items present).
|
||||
- [ ] OpenSubtitles v24 install + smoke test on a Polish-audio episode.
|
||||
- [ ] Re-test HW accel — ffmpeg 7.1.3 changed tonemap; verify 4K HDR R&M still transcodes.
|
||||
- [ ] Update prod compose to `jellyfin/jellyfin:10.10.7` → snapshot → `10.11.8`.
|
||||
- [ ] Re-run `bin/set-home-layout.py` against prod once on 10.11.8 — should now work via API alone, no DB hack needed.
|
||||
- [ ] Retire `bin/fix-home-db.sh` from the canonical playbook (kept as emergency-only).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollback (if scyfin or any 10.11.8 behaviour blocks promotion)
|
||||
|
||||
EF Core migrations are forward-only — 10.10.3 will not start against a 10.11.x DB.
|
||||
|
||||
```bash
|
||||
docker stop jellyfin-dev
|
||||
docker run --rm --userns=host -v /home/docker:/dst alpine sh -c \
|
||||
"apk add --no-cache zstd tar; cd /dst; rm -rf jellyfin-dev/config; \
|
||||
tar -I 'zstd -d' -xf /dst/snap/jellyfin-dev-pre-1011-upgrade-*.tar.zst"
|
||||
sed -i 's|10.11.8|10.10.3|' /opt/docker/jellyfin-dev/docker-compose.yml
|
||||
docker compose -f /opt/docker/jellyfin-dev/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
(Snapshot path is `/home/user/snapshots/`. Mount it into the alpine helper if needed.)
|
||||
186
docs/30-stock-jellyfin-tv-build.md
Normal file
186
docs/30-stock-jellyfin-tv-build.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# 30 — Stock Jellyfin rebuild on tv.s8n.ru (ground-up)
|
||||
|
||||
Date: 2026-05-11
|
||||
Scope: brand new container, brand new volumes, zero ARRFLIX customisation.
|
||||
Sister docs: 29 (the failed in-place dev upgrade that led here).
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision
|
||||
|
||||
After running the dev migration (10.10.3 → 10.11.8 + scyfin) on the existing
|
||||
`jellyfin-dev` container, the result still carried index.html shim, Cineplex
|
||||
remnants, and accumulated configuration drift. Owner asked for a true clean
|
||||
build instead.
|
||||
|
||||
Approach: new container, new domain, no shim, no CustomCss. Stock Jellyfin.
|
||||
We layer ARRFLIX brand on top once the bare server is happy.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deploy
|
||||
|
||||
```yaml
|
||||
# /opt/docker/jellyfin-stock/docker-compose.yml
|
||||
services:
|
||||
jellyfin-stock:
|
||||
image: jellyfin/jellyfin:10.11.8
|
||||
container_name: jellyfin-stock
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
userns_mode: "host"
|
||||
environment:
|
||||
- TZ=Europe/London
|
||||
- JELLYFIN_PublishedServerUrl=https://tv.s8n.ru
|
||||
volumes:
|
||||
- /home/docker/jellyfin-stock/config:/config
|
||||
- /home/docker/jellyfin-stock/cache:/cache
|
||||
- /home/user/media:/media:ro
|
||||
networks: [proxy]
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=proxy
|
||||
- traefik.http.routers.jellyfin-stock.rule=Host(`tv.s8n.ru`)
|
||||
- traefik.http.routers.jellyfin-stock.entrypoints=websecure
|
||||
- traefik.http.routers.jellyfin-stock.tls=true
|
||||
- traefik.http.routers.jellyfin-stock.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.jellyfin-stock.loadbalancer.server.port=8096
|
||||
```
|
||||
|
||||
Volumes initialised empty. No bind-mount of index.html — the stock web UI
|
||||
serves from the image as-is.
|
||||
|
||||
### DNS
|
||||
|
||||
```
|
||||
Pi-hole local DNS: <nullstone-LAN-IP> tv.s8n.ru
|
||||
onyx /etc/hosts: <nullstone-LAN-IP> tv.s8n.ru (appended to existing pin block)
|
||||
Public DNS (Gandi): none — LAN-only by design
|
||||
```
|
||||
|
||||
(LAN IP is the standard nullstone bind, see SYSTEM.md.)
|
||||
|
||||
`/opt/docker/pihole/etc-pihole/custom.list` is owned by root; we wrote via
|
||||
privileged Alpine container + `--userns=host` to bypass the userns-remap.
|
||||
Same trick used for the `/home/docker/jellyfin-stock/` dirs.
|
||||
|
||||
ServerId: `adbc441eb46e475c9610c3bd5258dc6e` (fresh, not migrated from prod).
|
||||
|
||||
---
|
||||
|
||||
## 3. Library scope (P1+P2)
|
||||
|
||||
User chose P1+P2 from `tv.s8n.ru` plan: libraries + canonical-ID lock only.
|
||||
No user import, no watched-state transfer, no plugins, no theme.
|
||||
|
||||
### Libraries added via API
|
||||
|
||||
```bash
|
||||
TOKEN=<admin token from Devices table after wizard>
|
||||
|
||||
curl -X POST -H "X-Emby-Token: $TOKEN" \
|
||||
"https://tv.s8n.ru/Library/VirtualFolders?name=Movies&collectionType=movies&paths=%2Fmedia%2Fmovies&refreshLibrary=false" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"LibraryOptions":{"EnableInternetProviders":true,"PreferredMetadataLanguage":"en","MetadataCountryCode":"US","SubtitleDownloadLanguages":["eng"],"SaveSubtitlesWithMedia":true,"RequirePerfectSubtitleMatch":false,"EnabledMetadataFetchers":["TheMovieDb","The Open Movie Database"],"MetadataFetcherOrder":["TheMovieDb","The Open Movie Database"]}}'
|
||||
|
||||
curl -X POST -H "X-Emby-Token: $TOKEN" \
|
||||
"https://tv.s8n.ru/Library/VirtualFolders?name=TV%20Shows&collectionType=tvshows&paths=%2Fmedia%2Ftv&refreshLibrary=false" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"LibraryOptions":{...same shape, fetchers=[TheMovieDb,TheTVDB]}}'
|
||||
|
||||
curl -X POST -H "X-Emby-Token: $TOKEN" "https://tv.s8n.ru/Library/Refresh"
|
||||
```
|
||||
|
||||
### Scan result
|
||||
|
||||
```
|
||||
MovieCount 4
|
||||
SeriesCount 12
|
||||
EpisodeCount 230
|
||||
```
|
||||
|
||||
### Auto-scrape outcome
|
||||
|
||||
10 / 12 series + 4 / 4 movies matched canonical IDs without intervention.
|
||||
Three unmatched, all expected:
|
||||
|
||||
```
|
||||
The Big Lez Saga (2022) TMDB --- (TMDB has no entry; Australian indie)
|
||||
The Donny & Clarence Show TMDB --- (IMDb tt32043762 only)
|
||||
Star Wars: Maul - Shadow Lord [Before Upscale] no IDs (intentional dupe folder)
|
||||
```
|
||||
|
||||
Matched IDs (sanity-checked against prod docs):
|
||||
|
||||
```
|
||||
American Dad! TMDB 1433
|
||||
Archer TMDB 10283
|
||||
Futurama TMDB 615 TVDB 73871 IMDb tt0149460
|
||||
The Mandalorian TMDB 82856
|
||||
The Mike Nolan Show TMDB 67160
|
||||
Obi-Wan Kenobi TMDB 92830
|
||||
Rick and Morty TMDB 60625
|
||||
Sassy the Sasquatch TMDB 321760
|
||||
Star Wars: Maul TMDB 289219
|
||||
Movies
|
||||
The Dark Knight TMDB 155
|
||||
Idiocracy TMDB 7512
|
||||
The Incredible Hulk TMDB 1724
|
||||
Lilo & Stitch TMDB 11544
|
||||
```
|
||||
|
||||
No `POST /Items/{id}` lock calls needed — the auto-scrape was clean.
|
||||
|
||||
---
|
||||
|
||||
## 4. Passwords
|
||||
|
||||
Admin `s8n` + user `guest` created via first-run wizard with throwaway
|
||||
passwords. Owner asked to use the same passwords as prod. Approach for that
|
||||
(deferred — pending owner decision):
|
||||
|
||||
```sql
|
||||
-- prod jellyfin.db Users.Password is $PBKDF2-SHA512$iterations=2100$<salt>$<hash>
|
||||
-- Copy hash from prod to stock:
|
||||
ATTACH '/path/to/prod-jellyfin.db' AS prod;
|
||||
UPDATE Users
|
||||
SET Password = (SELECT Password FROM prod.Users WHERE Username = Users.Username)
|
||||
WHERE Username IN ('s8n', 'guest');
|
||||
```
|
||||
|
||||
Run with container stopped. Verified the PBKDF2 hash includes the salt
|
||||
inline so copying the column is enough — no separate salt column.
|
||||
|
||||
---
|
||||
|
||||
## 5. Explicitly NOT done
|
||||
|
||||
- No theme (no scyfin, no Cineplex, no ElegantFin).
|
||||
- No `web-overrides/index.html` shim — stock Jellyfin chrome visible.
|
||||
- No CustomCss in `branding.xml` (file is the 225-byte default).
|
||||
- No plugins installed (no OpenSubtitles, no anything).
|
||||
- No 13-user import — only `s8n` admin + `guest`.
|
||||
- No home-section seed — stock defaults apply (smalllibrarytiles, resume,
|
||||
resumeaudio, nextup, latestmedia). Owner will iterate from here.
|
||||
- No backdrop pinning, no scrollbar themeing, no per-user prefs scripts.
|
||||
|
||||
---
|
||||
|
||||
## 6. State table
|
||||
|
||||
| Instance | Domain | Image | Theme | Brand | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| `jellyfin` (prod) | arrflix.s8n.ru | 10.10.3 | Cineplex v1.0.6 + INC1-7 patches | ARRFLIX | Untouched, real users on it |
|
||||
| `jellyfin-dev` | dev.arrflix.s8n.ru | 10.11.8 | scyfin OLED (broken brand-vs-shim mismatch) | ARRFLIX | Experimental — can be wiped |
|
||||
| `jellyfin-stock` | tv.s8n.ru | 10.11.8 | — | stock Jellyfin | Fresh, ready to configure |
|
||||
|
||||
---
|
||||
|
||||
## 7. Open follow-ups (none owed before owner sign-off)
|
||||
|
||||
- Decide fate of `jellyfin-dev` (keep / wipe / repurpose).
|
||||
- Owner explores stock UX → identifies what to brand vs leave alone.
|
||||
- Eventually layer ARRFLIX skin (logo, accent, dark scrollbar) on top of
|
||||
stock — incrementally, documenting each step.
|
||||
- If migration to 10.11.8 on prod is later approved: docs/29 staged
|
||||
10.10.3 → 10.10.7 → 10.11.8 path with snapshots is the playbook.
|
||||
195
docs/31-theme-layer-model-and-edit-guide.md
Normal file
195
docs/31-theme-layer-model-and-edit-guide.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# 31 — ARRFLIX theme layer model + edit guide (2026-05-09)
|
||||
|
||||
> **Read this before editing any CSS in `bin/inject-middle-theme.py`, `web-overrides/index.html`, or `branding.xml`.** Five black-screen-over-video incidents in 24 hours (doc 26 INC1–INC5, doc 28 INC7, doc 30 v6-stable, plus this latest one) all came from the same anti-pattern: an opaque `background-color` rule painted on an ancestor of `<video>` while the player is mounted. This doc maps the layer hierarchy and gives a checklist that catches the bug before it ships.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — checklist before adding a CSS rule
|
||||
|
||||
1. Does my rule paint `background-color`, `background`, or `background-image` on **any** of: `html`, `body`, `.backgroundContainer`, `.skinBody`, `.mainAnimatedPage(s)`, `.pageContainer`, `#reactRoot`, `.videoPlayerContainer`, `#videoOsdPage`, `.libraryPage`, `video.htmlvideoplayer`?
|
||||
- **Yes** → scope it with `body.arrflix-themed:not(.arrflix-video-active)`. Test on a video page after deploy.
|
||||
- **No** → safe to paint any color.
|
||||
2. Does my rule set a `z-index` on `<video>`, `.videoPlayerContainer`, or any element claiming to be "the player"?
|
||||
- **Yes** → STOP. Don't. OSD controls (scrubber, buttons, settings panel) sit above `<video>` via Jellyfin's stock z-indexes (1100–2000). Lifting the player above that obscures the controls — see image #12 incident, this very doc.
|
||||
- **No** → safe to z-index whatever.
|
||||
3. Did I add a `<video>` literal in a CSS comment? (e.g. `/* video element... */`).
|
||||
- If the rule lives in `branding.xml` `<CustomCss>`: ESCAPE it. `<video>` → `<video>`. Otherwise XML parser chokes, branding silently fails to load, theme disappears site-wide. See doc 30.
|
||||
- If the rule lives in `web-overrides/index.html` `<style>`: safe (HTML doesn't parse content of `<style>`).
|
||||
4. After deploying, hard-refresh and play any video. If you see a black/white frame instead of decoded pixels: revert and re-read this doc.
|
||||
|
||||
---
|
||||
|
||||
## The layer model
|
||||
|
||||
Stacking order, low → high. Ancestors of `<video>` listed first.
|
||||
|
||||
| Layer | Element | Stock z-index | ARRFLIX bg | Notes |
|
||||
|------:|---------|---------------|------------|-------|
|
||||
| 0 | `<html>` | n/a (root) | `#000` (JS inline-style pinned) | Shows behind transparent body during video — black letterbox bars come from here. |
|
||||
| 1 | `<body>` | n/a | `#000` off-video (L1) / `transparent` on-video (L2) | Toggled by JS body class `.arrflix-video-active`. |
|
||||
| 2 | `.backgroundContainer` | `-1` (Jellyfin) | follows L1/L2 | Holds the poster blur backdrop on detail pages. |
|
||||
| | `.skinBody` | `auto` | follows L1/L2 | Main app shell. |
|
||||
| | `#reactRoot` | `auto` | follows L1/L2 | React mount root. |
|
||||
| 3 | `.mainAnimatedPages` | `auto` | follows L1/L2 | Page swap container (animates between pages). |
|
||||
| | `.pageContainer` | `auto` | follows L1/L2 | Current page. |
|
||||
| 4 | `.skinHeader` | `1` | `#000` off-video, **HIDDEN** on-video | Top nav. Hidden when `body.arrflix-video-active` (and not on login). |
|
||||
| 5 | `.videoPlayerContainer` (`.videoPlayerContainer-onTop`) | `1000` (Jellyfin) | `transparent` on-video | The player wrapper. **NEVER override this z-index.** |
|
||||
| | └─ `<video class="htmlvideoplayer">` | `auto` (inherits) | `transparent` | Class is **lowercase** `htmlvideoplayer`. There is no `.htmlVideoPlayer` (camelCase). Don't confuse them. |
|
||||
| 6 | `.osdControls`, `.videoOsdBottom`, `.upNextDialog` | `~1100–1500` (Jellyfin) | varies per element | Scrubber, play/pause, fullscreen, captions, settings. **MUST stay above `<video>`.** |
|
||||
| 7 | `.dialogContainer`, `.dialog` | `~2000+` (Jellyfin) | varies | Modals (settings menu, audio/subtitle picker, info dialog). |
|
||||
|
||||
**Hard rule**: any z-index between 1000 and 2000 is owned by Jellyfin. Don't touch it.
|
||||
|
||||
---
|
||||
|
||||
## The two body classes
|
||||
|
||||
JS toggles two body classes on every `relayoutHeader()` tick (every page mutation + 1.5s interval + hashchange + DOMContentLoaded).
|
||||
|
||||
### `body.arrflix-themed`
|
||||
|
||||
- **Set when** `isAuthed()` returns true. Conditions: `ApiClient.isLoggedIn()`, `localStorage.jellyfin_credentials.Servers[0].AccessToken` exists, no visible `#loginPage`, hash not on `/login | /wizard | /forgotpassword | /selectserver`.
|
||||
- **Removed on** logout, login route, server picker.
|
||||
- **Effect**: gates the entire theme. Without this class, the page renders stock-Jellyfin (so login looks like Jellyfin's default sign-in form, not the rearranged Cineplex layout).
|
||||
|
||||
### `body.arrflix-video-active`
|
||||
|
||||
- **Set when** `isVideoPage()` returns true. Conditions (any one):
|
||||
- `location.hash` contains `/video`
|
||||
- `#videoOsdPage:not(.hide)` exists in DOM
|
||||
- `video.htmlvideoplayer:not(.hide)` exists and is `display:flex/block`
|
||||
- **Removed when** none of those signals match.
|
||||
- **Effect**: switches CSS from L1 (opaque #000 ancestors) to L2 (transparent ancestors), hides `.skinHeader`, hides `.arrflix-headerLogo`, hides `.arrflix-nav`.
|
||||
|
||||
---
|
||||
|
||||
## Cascade rules to know
|
||||
|
||||
### Specificity tiers used
|
||||
|
||||
| Selector form | Specificity (a,b,c) |
|
||||
|---------------|---------------------|
|
||||
| `body` | (0,0,1) |
|
||||
| `body.arrflix-themed` | (0,1,1) |
|
||||
| `body.arrflix-themed:not(.arrflix-video-active)` | (0,2,1) |
|
||||
| `body.arrflix-themed.arrflix-video-active` | (0,2,1) |
|
||||
| `body.arrflix-themed.arrflix-video-active .pageContainer` | (0,3,1) |
|
||||
| `body.arrflix-themed.arrflix-video-active #videoOsdPage` | (0,2,1) + ID = (1,2,1) |
|
||||
| `#videoOsdPage .pageContainer` (Cineplex/INC7) | (1,1,0) |
|
||||
|
||||
L1 (off-video) and L2 (on-video) both score (0,2,1) on body. Equal specificity → **source order decides**. L2 is listed AFTER L1 in `inject-middle-theme.py`, so during video L2 wins. If you reorder these blocks you reopen the bug.
|
||||
|
||||
### `!important` doesn't override specificity
|
||||
|
||||
Both L1 and L2 use `!important`. Among `!important` rules, specificity still decides. Adding `!important` to a low-specificity rule won't beat a high-specificity `!important` rule.
|
||||
|
||||
### Inline style beats stylesheets
|
||||
|
||||
`<html style="background-color:#000">` (set via `element.style.setProperty('background-color','#000','important')`) beats every stylesheet rule. We use this on `<html>` because `getComputedStyle(html).backgroundColor` inexplicably returned `rgba(0,0,0,0)` on details/video pages despite 5 stylesheet rules saying `#000 !important`. Likely a Chromium root-canvas-propagation quirk.
|
||||
|
||||
---
|
||||
|
||||
## CSS load order (top → bottom = first → last applied)
|
||||
|
||||
1. **`web-overrides/index.html` `<style>` (top, lines 1–63)** — critical CSS, painted before bundle. Includes the original `html, body, .preload, ... { background-color: #000 !important }` rule.
|
||||
2. **`<style>ARRFLIX-MIDDLE-THEME-BEGIN/END</style>`** — our middle-theme rules. Inserted just before `</head>`. After the critical CSS.
|
||||
3. **Jellyfin web bundle CSS** (`main.jellyfin.<hash>.css`, `themes/dark/theme.css`, lazy-loaded chunks). Loaded via `<link>`. Comes after our `<style>` block in the DOM but typically lower specificity, so we still win.
|
||||
4. **`branding.xml` `CustomCss`** — fetched at SPA boot, injected as a `<style>` element AFTER everything else. Includes `@import url('/web/cineplex.css')` which pulls in the Cineplex theme. Wins over inline `<style>` on equal specificity.
|
||||
|
||||
If you see a CSS rule from `branding.xml` overriding ours, increase specificity, not load order.
|
||||
|
||||
---
|
||||
|
||||
## Recurring bug list (the things this doc exists to prevent)
|
||||
|
||||
| Date | Bug | Cause | Fix |
|
||||
|------|-----|-------|-----|
|
||||
| 2026-05-09 INC1 | Backdrop band black | `BLACK-PASS` paints `.backdropContainer` opaque | scope `:has(.itemDetailPage)` transparent |
|
||||
| 2026-05-09 INC4 | "More from Season N" carousel hidden | `.emby-scroller{bg:#000}` unscoped | add `.emby-scroller` to transparent-scope |
|
||||
| 2026-05-09 INC7 | Video black-screen during playback | `.libraryPage{bg:#000}` paints over `<video>` | `#videoOsdPage{bg:transparent}` |
|
||||
| 2026-05-09 v6-stable | All Cineplex CSS missing site-wide | `<video>` literal in branding.xml comment broke XML parse | escape `<video>` → `<video>` |
|
||||
| 2026-05-09 a6cf925 | Body opaque during playback | `:has(.htmlVideoPlayer)` (camelCase) never matched | use `:not(.arrflix-video-active)` instead |
|
||||
| 2026-05-09 image-12 | Video covers OSD scrubber + buttons | We forced `<video> z-index: 9999` | revert; rely on Jellyfin's stock z-index hierarchy |
|
||||
|
||||
The pattern is the same every time: **a wrapper got an opaque background, and the negation didn't catch every wrapper Jellyfin uses on the video page.** This doc is the negation list.
|
||||
|
||||
---
|
||||
|
||||
## How to add a new theme rule safely
|
||||
|
||||
### Adding a NEW bg-color rule
|
||||
|
||||
1. Read the layer table above. If your selector lands on layer 0–4, scope with `body.arrflix-themed:not(.arrflix-video-active)`.
|
||||
2. Put the rule in `bin/inject-middle-theme.py`, in the section that matches its purpose (header layout, search input, etc.).
|
||||
3. Run `python3 bin/inject-middle-theme.py` to re-emit `web-overrides/index.html`.
|
||||
4. scp to dev only (don't touch prod yet).
|
||||
5. Hard-refresh dev login → confirm no visual regression.
|
||||
6. Hard-refresh dev → click any video → play 5+ seconds → confirm video pixels visible. If black: revert.
|
||||
7. Only then push to prod (`docker run --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine cp /tmp/idx.html /d/index.html && chown root:root && docker restart jellyfin`).
|
||||
|
||||
### Adding a NEW z-index
|
||||
|
||||
Don't. Period. If you think you need to z-index `<video>` higher to "be on top": you don't. Stock Jellyfin already gives you the right stacking. The bug was always opaque ancestor backgrounds, never z-index.
|
||||
|
||||
If you absolutely need z-index for a non-player element: stay below 1000 (everything below the player wrapper) or above 2000 (above all dialogs). Anything in between is a Jellyfin OSD/dialog territory.
|
||||
|
||||
### Adding a NEW selector to L2's transparent list
|
||||
|
||||
1. Confirm the selector lands on a real ancestor of `<video>` (open DevTools, navigate up the DOM tree from the video element).
|
||||
2. Add it to BOTH L1 (opaque) and L2 (transparent) selector lists. Always paired so the off-video state stays black.
|
||||
3. Same deploy flow as above.
|
||||
|
||||
---
|
||||
|
||||
## DO NOT DO list (the foot-guns)
|
||||
|
||||
| Don't | Reason |
|
||||
|-------|--------|
|
||||
| `body.arrflix-themed { background:#000 }` (unscoped) | Reopens black-screen bug. Always use `:not(.arrflix-video-active)`. |
|
||||
| `<video> { z-index: 9999 }` | Covers OSD scrubber and buttons. Image #12. |
|
||||
| `:has(.htmlVideoPlayer)` (camelCase) | Class doesn't exist. Use `.htmlvideoplayer` lowercase or just `:not(.arrflix-video-active)`. |
|
||||
| `<video>...</video>` literal in `branding.xml` `CustomCss` comment | XML parser chokes, branding silently fails. Escape with `<` `>`. |
|
||||
| Reorder L1 / L2 blocks in `inject-middle-theme.py` | Equal specificity → source order decides. L2 must come after L1. |
|
||||
| Add `!important` to "fix" a cascade conflict without understanding specificity | `!important` doesn't change specificity ordering among `!important` rules. Increase specificity instead. |
|
||||
| Hot-patch the deployed overlay (write directly to `/opt/docker/jellyfin/web-overrides/index.html`) | Drift between repo and prod. INC1 root cause per doc 26. Always edit `bin/inject-middle-theme.py`, regen, scp. |
|
||||
| `cp` to swap the prod overlay without `docker restart jellyfin` | bind-mount inode swap doesn't refresh container view. The new file lives at a different inode; container still serves the old one. Always restart prod after `cp`. |
|
||||
|
||||
---
|
||||
|
||||
## CI gates that would have caught past bugs (still TODO)
|
||||
|
||||
| Gate | Catches | Status |
|
||||
|------|---------|--------|
|
||||
| `xmllint --noout branding.xml` on every push | The v6-stable XML-parse-silent-failure (doc 30 lesson) | NOT IMPLEMENTED |
|
||||
| `darkPct` assertion in `bin/headless-test-v2.py` | Every black-screen-over-video incident (5 of them) | NOT IMPLEMENTED (per doc 30 + agent 4 history) |
|
||||
| Forgejo CI runner triggering headless test on push to `main` | Both above, automatically | NOT IMPLEMENTED |
|
||||
| Headless test asserts `.osdControls` is visible during playback | The image-12 z-index-too-high regression | NOT IMPLEMENTED |
|
||||
|
||||
If you implement any of these, mark it here.
|
||||
|
||||
---
|
||||
|
||||
## Quick verify after any theme change
|
||||
|
||||
Run this 4-step manual smoke on dev before pushing to prod:
|
||||
|
||||
```bash
|
||||
# 1. Login still works (theme disabled pre-auth, no Cineplex breakage)
|
||||
curl -s https://dev.arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN # = 1
|
||||
docker exec jellyfin-dev curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c # ~36000
|
||||
|
||||
# 2. Home page renders pure black (no #101010 stripe at bottom)
|
||||
# 3. Play any video → frames visible (no black/white overlay) → OSD scrubber + buttons clickable
|
||||
# 4. Hard-refresh and repeat 3 to catch first-paint regressions
|
||||
|
||||
# Headless equivalent (until darkPct lands in v2):
|
||||
docker run --rm --userns=host --network container:jellyfin-dev \
|
||||
mcr.microsoft.com/playwright/python:v1.49.0-noble \
|
||||
bash -c 'pip install --quiet playwright==1.49.0 && python -c "..."'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The theme is fragile in one specific way: any new opaque background rule on a wrapper class can hide the video. The two-class system (`arrflix-themed` + `arrflix-video-active`) plus the L1/L2 paired rules is the structural defence. The CI gates above would be the automated safety net. Until those exist, this checklist + the layer model is the best you have.
|
||||
96
docs/32-dev-container-wipe-2026-05-11.md
Normal file
96
docs/32-dev-container-wipe-2026-05-11.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# 32 — `jellyfin-dev` container wipe (2026-05-11)
|
||||
|
||||
Cleanup of the idle `jellyfin-dev` instance on nullstone. This was the
|
||||
scratch container used for the scyfin theme experiment + the 10.11.8 dev
|
||||
upgrade documented in `docs/29-jellyfin-10.11-upgrade-and-scyfin-migration.md`.
|
||||
The experiment is concluded; prod is being upgraded by a parallel agent
|
||||
and `jellyfin-stock` (tv.s8n.ru) covers the stock build, so dev has no
|
||||
remaining role.
|
||||
|
||||
## Pre-wipe state
|
||||
|
||||
```
|
||||
$ docker ps -a --filter name=jellyfin-dev
|
||||
CONTAINER ID IMAGE STATUS NAMES
|
||||
ecf97cddba6c jellyfin/jellyfin:10.11.8 Up 14 hours (healthy) jellyfin-dev
|
||||
|
||||
$ ls -la /opt/docker/jellyfin-dev/
|
||||
-rw-r--r-- 1 user user 1898 docker-compose.yml
|
||||
-rw-r--r-- 1 user user 1799 docker-compose.yml.bak.1778243059
|
||||
drwxrwxr-x 3 user user 4096 web-overrides/
|
||||
|
||||
$ ls -la /home/docker/jellyfin-dev/ (via privileged alpine, userns=host)
|
||||
drwxr-xr-x 5 1000 1000 cache/
|
||||
drwxr-xr-x 8 1000 1000 config/
|
||||
total: 200 MB
|
||||
|
||||
$ grep -lr "jellyfin-dev\|dev.arrflix" /opt/docker/traefik/config/
|
||||
(no matches — routing was via docker-provider labels only)
|
||||
|
||||
$ df -h /home
|
||||
/dev/mapper/keystone--vg-home 399G 284G 96G 75%
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
1. `docker stop jellyfin-dev && docker rm jellyfin-dev` — container removed.
|
||||
2. Privileged-alpine wipe of `/home/docker/jellyfin-dev/` (uid 1000 inside
|
||||
userns-remap, owner `100000:100000` on host — host `user` can't `rm` it
|
||||
directly, hence the `--userns=host` container):
|
||||
```
|
||||
docker run --rm --userns=host -v /home/docker:/d alpine \
|
||||
rm -rf /d/jellyfin-dev
|
||||
```
|
||||
3. `rm -rf /opt/docker/jellyfin-dev/` — compose file + web-overrides gone
|
||||
(owned by `user`, plain rm sufficient).
|
||||
4. Traefik docker-provider router vanished with the container — no
|
||||
file-provider yaml to clean up (verified via grep).
|
||||
|
||||
## Post-wipe verification
|
||||
|
||||
```
|
||||
$ docker ps -a --filter name=jellyfin-dev # empty
|
||||
$ ls /opt/docker/jellyfin-dev # ENOENT
|
||||
$ ls /d/jellyfin-dev (in alpine) # ENOENT
|
||||
$ curl -sk -o /dev/null -w "%{http_code}\n" \
|
||||
https://dev.arrflix.s8n.ru/ # 404 (Traefik, no backend)
|
||||
$ ls /home/docker | grep jellyfin # jellyfin, jellyfin-stock only
|
||||
$ ls /opt/docker | grep jellyfin # jellyfin, jellyfin-stock only
|
||||
```
|
||||
|
||||
Prod (`jellyfin` at `arrflix.s8n.ru`) and stock (`jellyfin-stock` at
|
||||
`tv.s8n.ru`) were both untouched and continue to serve traffic.
|
||||
|
||||
## What was kept
|
||||
|
||||
- `/home/user/snapshots/jellyfin-dev-pre-1011-upgrade-20260511-033309.tar.zst`
|
||||
(143 MB) — pre-upgrade rollback point.
|
||||
- `/home/user/snapshots/jellyfin-dev-post-10107-20260511-033839.tar.zst`
|
||||
(144 MB) — post-rollback snapshot from earlier today.
|
||||
Both stay in place as historical artefacts.
|
||||
- Pi-hole local-DNS pin `dev.arrflix.s8n.ru -> 192.168.0.100` — harmless,
|
||||
resolves to Traefik which now 404s. Left alone.
|
||||
- LE certificate for `dev.arrflix.s8n.ru` in `traefik/acme.json` — left
|
||||
alone; reusable if dev is ever rebuilt.
|
||||
|
||||
## Disk reclaimed
|
||||
|
||||
```
|
||||
before: 96G avail
|
||||
after: 96G avail (200 MB freed; below `df -h` rounding granularity
|
||||
on a 399 GB volume)
|
||||
```
|
||||
|
||||
`du -sh /home/docker/jellyfin-dev` reported 200 MB pre-wipe, so the
|
||||
freed-space figure is exact even though `df -h` can't resolve it.
|
||||
|
||||
## Rebuild path (if ever needed)
|
||||
|
||||
1. Restore `/home/docker/jellyfin-dev/` from one of the snapshots in
|
||||
`/home/user/snapshots/`.
|
||||
2. Recreate `/opt/docker/jellyfin-dev/docker-compose.yml` from
|
||||
`docs/29-jellyfin-10.11-upgrade-and-scyfin-migration.md` (compose
|
||||
block is inline in that doc).
|
||||
3. `docker compose up -d` — Traefik docker-provider re-attaches the
|
||||
router automatically, LE cert is already in acme.json so no fresh
|
||||
challenge needed.
|
||||
120
docs/32-nullstone-storage-upgrade-plan.md
Normal file
120
docs/32-nullstone-storage-upgrade-plan.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# 32 — Nullstone Storage Upgrade Plan
|
||||
|
||||
> Status: PLAN (2026-05-10). Hardware purchase + install pending.
|
||||
|
||||
## Why
|
||||
|
||||
ARRFLIX library growth — Rick and Morty S02-S08 import (~105 GB) would push `/home` from 70 % → 92 % full. Tight margin for transcodes, logs, future imports. Pre-emptive upgrade beats emergency cleanup.
|
||||
|
||||
## Current state
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Drive | Intel SSDPEKKF512G8 NVMe 512 GB (single) |
|
||||
| Slot | 1 NVMe (`nvme0n1`) |
|
||||
| LVM VG | `keystone-vg`, fully allocated (475 GB) |
|
||||
| LV `/home` | 406.2 GB, 263 GB used, 117 GB free (70 %) |
|
||||
| LV `/`, `/var`, `/tmp`, swap | 30/11/3/24 GB |
|
||||
| Mobo | MSI X470 Gaming Plus Max (MS-7B79) |
|
||||
| **Free slots** | **1× M.2 (M2_2)**, **6× SATA ports** |
|
||||
|
||||
Mobo has two M.2 slots:
|
||||
- `M2_1` — PCIe 3.0 ×4, **occupied** (Intel 512 GB)
|
||||
- `M2_2` — PCIe 2.0 ×4 / SATA, **free**
|
||||
|
||||
## Recommended path — 2nd NVMe in M2_2
|
||||
|
||||
| Capacity | Approx cost | Recommended |
|
||||
|---|---|---|
|
||||
| 1 TB NVMe | ~£60 | adequate |
|
||||
| 2 TB NVMe | ~£130 | **best** — leaves headroom for full Disney+ / Star Wars catalogue |
|
||||
| 4 TB NVMe | ~£250 | long-term, future-proof |
|
||||
|
||||
Specs: NVMe PCIe 3.0 ×4 (M.2 2280). Brand: WD SN770, Samsung 990 EVO, Crucial P310, Lexar NM790. M2_2 is PCIe 2.0 — drive will negotiate down (still ~1.5 GB/s — plenty for media).
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Power off nullstone.
|
||||
2. Open case, install drive in M2_2 slot.
|
||||
3. Boot.
|
||||
4. Verify: `lsblk` should show new `nvme1n1`.
|
||||
5. Partition: `sudo parted /dev/nvme1n1 mklabel gpt && sudo parted /dev/nvme1n1 mkpart primary 0% 100%`
|
||||
6. Add as LVM PV: `sudo pvcreate /dev/nvme1n1p1`
|
||||
7. Extend VG: `sudo vgextend keystone-vg /dev/nvme1n1p1`
|
||||
8. Extend LV: `sudo lvextend -l +100%FREE /dev/keystone-vg/home`
|
||||
9. Grow filesystem online: `sudo resize2fs /dev/keystone-vg/home`
|
||||
10. Verify: `df -h /home` should show new size, no reboot needed.
|
||||
|
||||
Total downtime: ~5 min for case open + boot. No reinstall.
|
||||
|
||||
## Alternative paths (if M.2 install blocked)
|
||||
|
||||
### SATA SSD/HDD (option 4 from poll)
|
||||
|
||||
- 6 SATA ports free. Mobo has space for 2.5"/3.5" drives (case-dependent).
|
||||
- 2 TB SATA SSD ~£80, 2 TB HDD ~£25, 4 TB HDD ~£60.
|
||||
- Procedure same as NVMe but `/dev/sda` instead of `nvme1n1`.
|
||||
- HDD slower (~150 MB/s vs NVMe's 1500 MB/s) but fine for media — 4K HDR HEVC peaks at ~15 Mbps = 1.9 MB/s read.
|
||||
- Pro: cheapest per GB. Con: slower transcodes if SSD cache not configured.
|
||||
|
||||
### USB external (option 3 from poll)
|
||||
|
||||
- USB 3.0/3.1 ports available (at least 2× 10 Gbps slots).
|
||||
- Plug-and-play, no case open.
|
||||
- Pro: no install risk, can move drive between machines.
|
||||
- Con: cable disconnect risks media server uptime (auto-mount needs udev rule or fstab `nofail`). Slower than internal (~400-500 MB/s real-world over USB 3.1).
|
||||
- Best if: temporary expansion or testing.
|
||||
|
||||
## Migration of `/home/user/media`
|
||||
|
||||
Once new drive added to LVM and lv-home extended → media stays in place. NO migration needed. Just more headroom.
|
||||
|
||||
If using a SEPARATE drive (not joining LVM):
|
||||
|
||||
```bash
|
||||
# Mount new drive at /mnt/media-2
|
||||
sudo mkfs.ext4 /dev/nvme1n1p1 # or /dev/sda1
|
||||
sudo mkdir /mnt/media-2
|
||||
sudo mount /dev/nvme1n1p1 /mnt/media-2
|
||||
sudo chown user:user /mnt/media-2
|
||||
|
||||
# Move TV to new drive (movies stay on /home for now)
|
||||
sudo rsync -aHAX --info=progress2 /home/user/media/tv/ /mnt/media-2/tv/
|
||||
|
||||
# Update fstab for auto-mount on boot
|
||||
echo '/dev/nvme1n1p1 /mnt/media-2 ext4 defaults 0 2' | sudo tee -a /etc/fstab
|
||||
|
||||
# Update Jellyfin container — stop, edit docker-compose.yml volume:
|
||||
# /mnt/media-2/tv → /media/tv-2
|
||||
# OR just mount-bind /mnt/media-2/tv onto /home/user/media/tv (transparent to Jellyfin):
|
||||
sudo umount /home/user/media/tv # if anything's there
|
||||
sudo rmdir /home/user/media/tv
|
||||
sudo ln -s /mnt/media-2/tv /home/user/media/tv
|
||||
```
|
||||
|
||||
LVM-extension path is much cleaner — recommended.
|
||||
|
||||
## Decision pending
|
||||
|
||||
Owner picks NVMe / SATA / USB based on what's easier to get + budget. Update this doc after decision + install.
|
||||
|
||||
## After upgrade — Rick and Morty bulk import
|
||||
|
||||
Once `/home` has ≥ 200 GB free:
|
||||
|
||||
```bash
|
||||
# Per playbooks/import-media/ v1.0
|
||||
# Stage all 7 seasons on onyx (already done — names need normalising)
|
||||
# rsync to nullstone in one bulk
|
||||
# Force Library/Refresh
|
||||
# Verify Series 12→13 (only 1 new — R&M existed since S01), Episodes 217→285 (+68 with all 7 seasons full)
|
||||
# Wait for S03 E06+E10 to finish download first, OR import partial + add later
|
||||
```
|
||||
|
||||
Run logs: `playbooks/import-media/runs/rick-and-morty-s02-s08-2160p-hdr.md` (template).
|
||||
|
||||
## See also
|
||||
|
||||
- `playbooks/import-media/` — import workflow
|
||||
- `docs/05-file-structure-rules.md` — TV folder layout
|
||||
- ROADMAP.md — track upgrade as item
|
||||
15
playbooks/README.md
Normal file
15
playbooks/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# playbooks/ — moved
|
||||
|
||||
The procedural playbooks (README, CHANGELOG, helper scripts) have moved to
|
||||
beta-flix:
|
||||
|
||||
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/>
|
||||
|
||||
Per-run logs stay here under `playbooks/<area>/runs/` — they document past
|
||||
ARRFLIX work and are history, not procedure. New run logs for ARRFLIX
|
||||
imports continue to land here.
|
||||
|
||||
| Sub-area | Procedure | Run logs |
|
||||
|---|---|---|
|
||||
| Import media | [`beta-flix/playbooks/import-media/`](https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/import-media/) | [`import-media/runs/`](import-media/runs/) |
|
||||
| Subtitles | [`beta-flix/playbooks/subtitles/`](https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/subtitles/) | [`subtitles/runs/`](subtitles/runs/) |
|
||||
21
playbooks/import-media/CHANGELOG.md
Normal file
21
playbooks/import-media/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Import-media playbook changelog
|
||||
|
||||
## v1.1 — 2026-05-10
|
||||
|
||||
Two Jellyfin endpoint bugs found during `archer-s02-2009` run, codified into the playbook:
|
||||
|
||||
- **`POST /Library/Refresh` is a silent no-op** on this Jellyfin build — returns HTTP 204 but the `Scan Media Library` scheduled task does NOT execute. Step 4 rewritten to fetch the scan-task ID from `/ScheduledTasks` and POST to `/ScheduledTasks/Running/<id>` instead. Old endpoint added to a "known broken" table.
|
||||
- **`/Items/Counts` is scope-cached** and stays stale even after items are indexed (counts stayed at 230 after 13 new eps landed). Step 5 rewritten to use the per-series authoritative query `/Shows/<id>/Episodes?Season=<NN>` with provider + image-tag verification, plus a per-series `Items/<id>/Refresh` recipe for missing metadata.
|
||||
- LibraryMonitor inotify auto-fire wording removed from Step 4 (failed on both recorded runs — Lilo & Stitch + Archer S02). Manual task trigger is now mandatory.
|
||||
- Verification checklist updated to reference the task-trigger endpoint and per-series query.
|
||||
- Rollback section: replaced `/Library/Refresh` invocation with `/ScheduledTasks/Running/<id>`.
|
||||
|
||||
## v1.0 — 2026-05-10
|
||||
|
||||
Initial playbook. 7 steps from staging on onyx → rsync to nullstone → verify scan + counts → optional subtitle pass → run-log.
|
||||
|
||||
Gaps flagged for future versions:
|
||||
- v1.2 will add canonical `bin/import-media.sh` wrapper once `bin/cleanup-import.sh` and `bin/normalize.py` are extracted from docs/07 and docs/08 (ROADMAP M6). (Bumped from v1.1 since v1.1 was needed for the Jellyfin endpoint fixes.)
|
||||
- v1.3 will add a TV multi-season import section (currently only single-season example).
|
||||
- v1.4 will add NFO override pattern with worked example for a wrong-TMDb-match recovery.
|
||||
- v1.5: document API-token retrieval (mint a permanent ApiKeys row labelled `import-pipeline` instead of pulling a session token from `Devices` table).
|
||||
10
playbooks/import-media/README.md
Normal file
10
playbooks/import-media/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Import-media playbook — moved
|
||||
|
||||
The procedure has moved to beta-flix and been rewritten for stock Jellyfin
|
||||
10.11.8:
|
||||
|
||||
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/import-media/README.md>
|
||||
|
||||
Per-import run logs stay here under [`runs/`](runs/) — they're history,
|
||||
not procedure. [`CHANGELOG.md`](CHANGELOG.md) preserves the ARRFLIX
|
||||
playbook evolution (v1.0 → v1.1) for context.
|
||||
49
playbooks/import-media/runs/_template.md
Normal file
49
playbooks/import-media/runs/_template.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# <title-slug>
|
||||
|
||||
> Per-import run log. Mirror `playbooks/subtitles/runs/_template.md` style.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source path on onyx:** `/home/admin/Downloads/<release-name>/<file>.mkv`
|
||||
- **Release group:** YOGI / RARBG / FQM / etc.
|
||||
- **Quality:** 1080p BluRay HEVC 10-bit / 2160p WEB-DL / etc.
|
||||
- **Audio:** EAC3 5.1 / DTS-HD MA / etc.
|
||||
|
||||
## Target
|
||||
|
||||
- **Library:** movies / tv
|
||||
- **Path:** `/home/user/media/<lib>/<Title> (<Year>)/<...>`
|
||||
- **Container view:** `/media/<lib>/<Title> (<Year>)/<...>`
|
||||
- **Item ID:** (after first scan)
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| MovieCount or EpisodeCount | | | |
|
||||
|
||||
## Stream summary
|
||||
|
||||
```
|
||||
ffprobe output here — Video / Audio / Subtitle streams
|
||||
```
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: yes/no, count, langs
|
||||
- External sidecar: yes/no, path
|
||||
- Action: none / playbooks/subtitles run
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [ ] Folder/filename canonical
|
||||
- [ ] Permissions user:user 644 / 755
|
||||
- [ ] LibraryMonitor auto-fired (log line)
|
||||
- [ ] Items/Counts bumped by N
|
||||
- [ ] TMDb / TVDB metadata populated
|
||||
- [ ] Artwork loaded
|
||||
- [ ] Direct-play in client (no transcode line)
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
(any unusual filename normalisation, NFO override, or post-import tweak)
|
||||
70
playbooks/import-media/runs/archer-2009-s01.md
Normal file
70
playbooks/import-media/runs/archer-2009-s01.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# archer-2009-s01
|
||||
|
||||
Third run of `playbooks/import-media/` v1.0.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source path on onyx:** `/home/admin/Downloads/Archer Season 1 [1080p AI 10bit S94 Joy]/`
|
||||
- **Release group:** Joy / S94
|
||||
- **Quality:** 1080p AI-upscale x265 10-bit
|
||||
- **Audio:** HE-AAC 5.1 ENG
|
||||
- **Subtitles:** 3× embedded DVDsub (ENG / SPA / FRE) per episode
|
||||
- **Episode count:** 10 (S01E01–E10)
|
||||
- **Total size:** 2.2 GB
|
||||
|
||||
Per `README.md:41` quality bar — AI-upscaled masters allowed when source doesn't support 4K. Original Archer FX broadcast was 720p; 1080p AI-upscale acceptable.
|
||||
|
||||
## Target
|
||||
|
||||
- **Library:** tv
|
||||
- **Path:** `/home/user/media/tv/Archer (2009)/Season 01/`
|
||||
- **Container view:** `/media/tv/Archer (2009)/Season 01/`
|
||||
- **Series Item ID:** `9d22c409d531...`
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| SeriesCount | 11 | 12 | +1 |
|
||||
| EpisodeCount | 207 | 217 | +10 |
|
||||
|
||||
## Stream sample (E01 — Mole Hunt)
|
||||
|
||||
```
|
||||
Duration: 00:21:32.83, bitrate: 1415 kb/s
|
||||
Stream #0:0: Video: hevc, none, 1920x1080, 23.98 fps
|
||||
Stream #0:1(eng): Audio: aac (HE-AAC), 48000 Hz, 5.1, fltp
|
||||
Stream #0:2(eng): Subtitle: dvd_subtitle (dvdsub), 1920x1080
|
||||
Stream #0:3(spa): Subtitle: dvd_subtitle (dvdsub), 1920x1080
|
||||
Stream #0:4(fre): Subtitle: dvd_subtitle (dvdsub), 1920x1080
|
||||
```
|
||||
|
||||
HEVC 1080p (8-bit per `Video: hevc, none`), 1.4 Mbps, ~21 min runtime.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- 3× embedded DVDsub (image-based) per episode — ENG / SPA / FRE
|
||||
- DVDsub renders via server burn-in or external picker; no text VTT fallback
|
||||
- If owner wants text subs (cleaner, browser-native), follow `playbooks/subtitles/` to drop external `.eng.srt` later
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`Archer (2009)/Season 01/Archer (2009) - S01E<NN> - <Title>.mkv`)
|
||||
- [x] Permissions `user:user` 644 / 755
|
||||
- [ ] LibraryMonitor auto-fired — DID NOT (third consecutive miss after Lilo + Maul). Forced manual `POST /Library/Refresh` 204 → counts bumped within ~90 s.
|
||||
- [x] `Items/Counts` Series 11→12, Episodes 207→217
|
||||
- [x] Series enumerated as new item `9d22c409d531...`
|
||||
- [ ] **TMDb / TVDB providers**: NOT auto-matched (`tmdb=? tvdb=?`). Same as Maul run. Operator to manually identify via UI.
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
- Filename normalization: source had `Archer S01E01 Mole Hunt [1080p x265 10bit Joy].mkv` (double space, group brackets). Stripped to `Archer (2009) - S01E01 - Mole Hunt.mkv` per `docs/08`. Episode-title list compiled from filenames — no source NFO available.
|
||||
- LibraryMonitor failure pattern is now **3-for-3** on TV imports — confirmed not flaky, it's broken. Playbook v1.1 must call out manual `/Library/Refresh` as MANDATORY (not optional fallback).
|
||||
- TMDb auto-match failed for the third time in three imports. Worth investigating: maybe the TMDb plugin token / library option `EnableInternetProviders` is off for the TV library. Operator: check Dashboard → Libraries → TV Shows → Metadata downloaders.
|
||||
|
||||
## Operator action
|
||||
|
||||
1. Open `https://arrflix.s8n.ru` → search "Archer" → confirm series visible.
|
||||
2. Manually identify if metadata desired: 3-dot → "Identify" → search TMDb (probably ID `10283`).
|
||||
3. Verify direct-play (HEVC HE-AAC should direct-play on most clients; DVDsub burn-in on transcode if needed).
|
||||
4. Source download at `/home/admin/Downloads/Archer Season 1 [1080p AI 10bit S94 Joy]/` retained until owner confirms playback.
|
||||
103
playbooks/import-media/runs/archer-s02-2009.md
Normal file
103
playbooks/import-media/runs/archer-s02-2009.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# archer-s02-2009
|
||||
|
||||
Second run of `playbooks/import-media/` v1.0. First TV-season run (Lilo & Stitch was a movie).
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source path on onyx:** `/home/admin/Downloads/Archer Season 2 [1080p AI 10bit S91 Joy]/`
|
||||
- **Release group:** Joy (AI-upscale tag `S91`)
|
||||
- **Quality:** 1080p HEVC 10-bit (Main 10), AI-upscaled from SD source
|
||||
- **Audio:** HE-AAC 5.1 English
|
||||
- **Embedded subs:** 3× DVD bitmap (eng, spa, fre)
|
||||
|
||||
## Target
|
||||
|
||||
- **Library:** tv
|
||||
- **Path:** `/home/user/media/tv/Archer (2009)/Season 02/`
|
||||
- **Container view:** `/media/tv/Archer (2009)/Season 02/`
|
||||
- **Series Item ID:** (existed pre-import; not re-fetched this run)
|
||||
- **TVDB / TMDb:** matched on existing `Archer (2009)` series folder (Season 1 already in lib)
|
||||
|
||||
## Files imported (13)
|
||||
|
||||
```
|
||||
Archer (2009) - S02E01 - Swiss Miss.mkv
|
||||
Archer (2009) - S02E02 - A Going Concern.mkv
|
||||
Archer (2009) - S02E03 - Blood Test.mkv
|
||||
Archer (2009) - S02E04 - Pipeline Fever.mkv
|
||||
Archer (2009) - S02E05 - The Double Deuce.mkv
|
||||
Archer (2009) - S02E06 - Tragical History.mkv
|
||||
Archer (2009) - S02E07 - Movie Star.mkv
|
||||
Archer (2009) - S02E08 - Stage Two.mkv
|
||||
Archer (2009) - S02E09 - Placebo Effect.mkv
|
||||
Archer (2009) - S02E10 - El Secuestro.mkv
|
||||
Archer (2009) - S02E11 - Jeu Monégasque.mkv
|
||||
Archer (2009) - S02E12 - White Nights.mkv
|
||||
Archer (2009) - S02E13 - Double Trouble.mkv
|
||||
```
|
||||
|
||||
Total ~2.8 GiB.
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| Episodes in `Archer (2009)` S02 | 0 | 13 | +13 ✅ |
|
||||
| SeriesCount | 12 | 12 | 0 (series existed) |
|
||||
| MovieCount | 4 | 4 | 0 |
|
||||
| `/Items/Counts.EpisodeCount` global | 230 | 230 (stale) | endpoint scope-cached, not authoritative |
|
||||
|
||||
**Authoritative verify:** `GET /Shows/9d22c409d5319c3c6068cfd38569714f/Episodes?Season=2` → 13 items, all with `ProviderIds.Tvdb`+`Imdb`+`TvRage`, Primary image present, paths resolve.
|
||||
|
||||
## Stream summary (S02E01 sample)
|
||||
|
||||
```
|
||||
Duration: 00:21:03.48, bitrate: 1452 kb/s
|
||||
Stream #0:0: Video: hevc (Main 10), yuv420p10le(tv), 1920x1080, SAR 1:1 DAR 16:9, 23.98 fps
|
||||
Stream #0:1(eng): Audio: aac (HE-AAC), 48000 Hz, 5.1, fltp
|
||||
Stream #0:2(eng): Subtitle: dvd_subtitle (dvdsub), 1920x1080
|
||||
Stream #0:3(spa): Subtitle: dvd_subtitle (dvdsub), 1920x1080
|
||||
Stream #0:4(fre): Subtitle: dvd_subtitle (dvdsub), 1920x1080
|
||||
```
|
||||
|
||||
HEVC 10-bit Main10 — direct-play on most clients. DVD-bitmap subs (`dvd_subtitle`) — server burn-in works, but per ARRFLIX subtitle style (1× plain English `.srt` only, no SDH/forced) these may need WhisperX text rebuild later. Add to `playbooks/subtitles/STOPGAP-SUBS.md` if user wants text subs.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: yes — 3× DVD bitmap (eng, spa, fre)
|
||||
- External sidecar: none yet
|
||||
- Action: none for now. Per ARRFLIX style, only English sidecar `.srt` is canonical; embedded multi-lang DVD-subs do not satisfy that. Defer to subtitle playbook if user wants text-based eng.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`Archer (2009)/Season 02/Archer (2009) - S02E<NN> - <Title>.mkv`)
|
||||
- [x] Permissions `user:user` 644 (file) / 755 (dir) — `ls -la` post-rsync confirmed
|
||||
- [ ] LibraryMonitor auto-fired — **DID NOT trigger** (same as Lilo run; bind-mount inotify flake confirmed as a pattern, not one-off)
|
||||
- [x] `POST /Library/Refresh` returned 204 but did NOT trigger task (silent no-op)
|
||||
- [x] `POST /ScheduledTasks/Running/<scan-task-id>` returned 204 → task ran → episodes added
|
||||
- [x] All 13 eps indexed with `Tvdb`+`Imdb`+`TvRage` providers populated
|
||||
- [x] Per-episode Primary artwork present (`ImageTags.Primary` set on all 3 sampled)
|
||||
- [x] HEVC 10-bit + HE-AAC → direct-play candidate on most clients
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
- LibraryMonitor flake confirmed: same as Lilo run. v1.0 playbook wording ("auto-refreshes ~1–3 s") is wrong; force-refresh is mandatory. Update to README.
|
||||
- **`POST /Library/Refresh` is also a silent no-op** in this Jellyfin build. Returned 204 but the `Scan Media Library` task didn't fire and counts stayed flat. Had to fetch the task ID from `/ScheduledTasks` and POST to `/ScheduledTasks/Running/<id>` directly. Update playbook to use task-trigger endpoint, not `/Library/Refresh`.
|
||||
- **`/Items/Counts` is scope-cached/stale** — kept returning 230 even after 13 new eps were indexed. Use `/Shows/<series-id>/Episodes?Season=N` as authoritative count source, not `/Items/Counts`.
|
||||
- API token retrieval: no docs in repo for getting an admin token. ApiKeys table empty (no operator-created keys). Pulled an active web-session token from `Devices` table via temp Alpine container mounting `jellyfin.db` read-only (`docker run --rm --userns=host -v ...:ro alpine sh -c "apk add sqlite && sqlite3 /db.sqlite ..."`). Per-session tokens work as `X-Emby-Token` for admin operations when source user has admin role. Worth documenting in ADMIN-GUIDE — or better, create an explicit ApiKeys row labelled "import-pipeline" with permanent rotation policy.
|
||||
- Source filenames had double-space before `[1080p ...]` group tag — handled by `${f%% [*}` parameter expansion. May need to be more lenient in future imports (single-space variants, no-space variants).
|
||||
- HE-AAC audio at 5.1 may transcode-pressure some clients (LG WebOS notably struggles with HE-AAC multichannel). Watch for transcode lines in `docker logs jellyfin | grep transcode` if Archer playback shows lag.
|
||||
- Source download on laptop retained per `ADMIN-GUIDE.md:74` — DO NOT delete `/home/admin/Downloads/Archer Season 2 ...` until user confirms playback in browser.
|
||||
|
||||
## Operator action
|
||||
|
||||
User to verify in browser: `https://arrflix.s8n.ru` → Archer (2009) → Season 02 → spot-check episodes 1, 7, 13 → confirm artwork + Play. After confirmed, source download on onyx can be deleted.
|
||||
|
||||
## Pending follow-ups
|
||||
|
||||
1. Update `playbooks/import-media/README.md`:
|
||||
- Drop "LibraryMonitor auto-fires" wording.
|
||||
- Replace `/Library/Refresh` with `/ScheduledTasks/Running/<scan-task-id>` (true task trigger).
|
||||
- Add note: do NOT use `/Items/Counts` as verification source (cached); use `/Shows/<id>/Episodes?Season=N` per-series.
|
||||
2. Document API-token retrieval in `ADMIN-GUIDE.md` (DB pull recipe or instructions to mint an ApiKey row labelled `import-pipeline`).
|
||||
3. Consider adding a `bin/import-tv.sh` that wraps stage → rsync → chmod → task-trigger → poll-by-series.
|
||||
126
playbooks/import-media/runs/benn-jordan-s01-yt-import.md
Normal file
126
playbooks/import-media/runs/benn-jordan-s01-yt-import.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# benn-jordan-s01-yt-import
|
||||
|
||||
First import into the **STOCK** Jellyfin at `tv.s8n.ru` (container `jellyfin-stock`),
|
||||
Educational library. YouTube videos from channel "Benn Jordan" — treated as one
|
||||
Series (`Benn Jordan`) on Season 01.
|
||||
|
||||
Independent from arrflix prod (`arrflix.s8n.ru`) and arrflix dev. Stock Jellyfin's
|
||||
Educational library has `EnableInternetProviders=false` — files land with
|
||||
filename/folder-only metadata. **No TMDb/TVDB matching is expected or attempted.**
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source:** YouTube channel "Benn Jordan" (2026 uploads)
|
||||
- **Tool:** `yt-dlp` 2026.03.17 on onyx
|
||||
- **Format selector:** `bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b` → `--merge-output-format mp4`
|
||||
- **Subs:** `--write-subs --sub-langs "en.*" --embed-subs --convert-subs srt` (no en subs available on these uploads — only auto-generated; not embedded)
|
||||
- **Staging path on onyx:** `/home/admin/staging-jelly/Benn Jordan/Season 01/`
|
||||
|
||||
### Source URLs
|
||||
|
||||
| Episode | Video ID | URL |
|
||||
|---|---|---|
|
||||
| S01E01 | n/a (pre-staged) | already downloaded before this run |
|
||||
| S01E02 | UMIwNiwQewQ | https://www.youtube.com/watch?v=UMIwNiwQewQ |
|
||||
| S01E03 | _bP80DEAbuo | https://www.youtube.com/watch?v=_bP80DEAbuo |
|
||||
| S01E04 | lA8WuXDXfcI | https://www.youtube.com/watch?v=lA8WuXDXfcI |
|
||||
|
||||
## Target
|
||||
|
||||
- **Server:** `jellyfin-stock` (container) on nullstone, exposed at `https://tv.s8n.ru`
|
||||
- **Library:** Educational (tvshows-type, internet providers disabled)
|
||||
- **Path on host:** `/home/user/media/educational/Benn Jordan/Season 01/`
|
||||
- **Container view:** `/media/educational/Benn Jordan/Season 01/`
|
||||
- **Series Item ID:** `3da50e01252c1463cb23f7b9499dfc8a`
|
||||
|
||||
### Per-episode landing
|
||||
|
||||
| Episode | File size | Duration (spec) | Duration (Jellyfin) | Item ID |
|
||||
|---|---:|---:|---:|---|
|
||||
| S01E01 — Gadgets For People Who Don't Trust The Government | 1,570,389,432 B (~1.46 GiB) | n/a (pre-staged) | 2337 s | `962cd81a9b2b80979a4ea10d6b12b922` |
|
||||
| S01E02 — It's Time to Take Down your Smart Cameras | 834,026,444 B (~795 MiB) | 1769 s | 1769 s | `fd24b71285bd5d8586687ba666988088` |
|
||||
| S01E03 — Datacenters Behaving Like Acoustic Weapons | 1,446,732,628 B (~1.35 GiB) | 1744 s | 1744 s | `6dca7b57158f37acd8d821f585501998` |
|
||||
| S01E04 — Robot Dogs Are A Security Nightmare | 772,469,473 B (~737 MiB) | 1432 s | 1432 s | `6ed3fab3c2b34c4718a0f836a638eea0` |
|
||||
|
||||
S01E02 title had the emoji `😬` stripped before download (per filename rules).
|
||||
All apostrophes preserved. No forbidden chars (`< > : " / \ | ? *`) introduced.
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| SeriesCount (Educational) | 0 | 1 | +1 |
|
||||
| EpisodeCount (Educational) | 0 | 4 | +4 |
|
||||
|
||||
(First import into this library; pre-state is empty.)
|
||||
|
||||
## Stream sample (S01E02)
|
||||
|
||||
```
|
||||
Duration: 00:29:28.66, bitrate: 3772 kb/s
|
||||
Stream #0:0(und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 3840x2160, 3639 kb/s, 23.98 fps
|
||||
Stream #0:1(und): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
|
||||
```
|
||||
|
||||
AV1 2160p at ~3.6 Mb/s, stereo AAC. Source is YouTube best mp4/m4a combo. No
|
||||
DRM, no encryption. AV1 direct-play requires a recent client (Chromium >= 90,
|
||||
Firefox >= 100, Apple Silicon Safari, Android 12+, modern smart-TVs).
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: no (YouTube auto-CC not requested via `en.*` glob; user-uploaded
|
||||
subs do not exist on these uploads).
|
||||
- External sidecar: no.
|
||||
- Action: none. Per Educational library convention these are short-form videos
|
||||
with on-screen text. Re-run with `--write-auto-subs --sub-langs "en.*"` later
|
||||
if subs become required.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`Benn Jordan/Season 01/Benn Jordan - S01E<NN> - <Title>.mp4`)
|
||||
- [x] Permissions `user:user` 644 / 755 on nullstone
|
||||
- [x] `Scan Media Library` task triggered via `/ScheduledTasks/Running/$SCAN_ID` — completed
|
||||
- [x] Per-series query returns 4 episodes with correct durations (1769/1744/1432 s for E02/E03/E04)
|
||||
- [x] No `/Items/Counts` reliance — used `/Shows/<id>/Episodes` as authoritative
|
||||
- [n/a] `ProviderIds` populated — **expected empty**, library has internet providers OFF
|
||||
- [n/a] Image artwork — none auto-fetched; folder-level posters may be added manually
|
||||
|
||||
### Scan task
|
||||
|
||||
- **Task ID:** `7738148ffcd07979c7ceb148e06b3aed`
|
||||
- **POST result:** HTTP 204
|
||||
- **LastExecutionResult.EndTimeUtc:** `2026-05-11T14:27:49.205Z`
|
||||
- **State after run:** `Idle`
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
- Stock Jellyfin's Educational library is configured `tvshows`-type with
|
||||
`EnableInternetProviders=false`. This is *intentional* — these are
|
||||
per-channel YouTube videos, not broadcast TV. Names and durations come from
|
||||
the filename and the container itself. **Do not try to TMDb-identify Benn
|
||||
Jordan; there is no matching entry.**
|
||||
- All 3 downloads ran in parallel from onyx and completed in well under one
|
||||
rsync window. Combined nullstone delivery via single `rsync -a` of the whole
|
||||
`Benn Jordan/` dir (E01 was already on disk from a prior staging — rsync no-op
|
||||
for that file thanks to size+mtime match).
|
||||
- AV1 codec is the default YouTube best-quality video stream as of 2026. None
|
||||
of the recipient devices (onyx, nullstone clients, etc.) have a problem
|
||||
direct-playing AV1 — but if a friend on a 2018 laptop reports playback issues,
|
||||
Jellyfin will transcode (CPU only — `jellyfin-stock` has no GPU mount per
|
||||
SYSTEM.md).
|
||||
- Educational library uses tvshows scheme, so episodes nest under one parent
|
||||
Series named exactly "Benn Jordan" with no year suffix (matches the folder
|
||||
name). Filename pattern is the same `<Series> - S<NN>E<MM> - <Title>` shape
|
||||
arrflix uses — no special-case required.
|
||||
- Source staging dir on onyx (`/home/admin/staging-jelly/Benn Jordan/`) is
|
||||
intentionally left in place — do not delete until owner confirms playback.
|
||||
|
||||
## Operator action
|
||||
|
||||
1. Open `https://tv.s8n.ru` → Educational library → confirm "Benn Jordan"
|
||||
series shows 4 episodes.
|
||||
2. Play any episode → confirm direct-play (no transcode line in
|
||||
`docker logs jellyfin-stock`).
|
||||
3. Optional: upload custom series + episode artwork via the Jellyfin web UI
|
||||
(no TMDb fallback, so artwork has to be manual or absent).
|
||||
4. Source dir on onyx retained per cleanup policy.
|
||||
87
playbooks/import-media/runs/futurama-s08-s11-20260513.md
Normal file
87
playbooks/import-media/runs/futurama-s08-s11-20260513.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Futurama (1999) — S08–S11 import (Disney+ WEB-DL)
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Operator:** s8n
|
||||
**Library:** `tv` (`/home/user/media/tv/`)
|
||||
**Target series:** Futurama (1999) — id `41953789dc06ede61bd1165fe5b96b2d`
|
||||
|
||||
---
|
||||
|
||||
## Source
|
||||
|
||||
- Path on onyx: `/home/admin/Downloads/Futurama Season 1-11 Colection 1080p WEBDL/`
|
||||
- Release: `Futurama (1999) Season 8/9/10/11 (1080p DSNP WEB-DL x265 HEVC 10bit EAC3 5.1 t3nzin) [ext.to]`
|
||||
- Magnet btih: `582B5D7C462473F13E35439F0D83F35A3888E6E3` (and sibling torrents per season)
|
||||
- Folder size: 19.7 GB / 49 files
|
||||
- Stream profile: HEVC Main10 1080p, EAC3 5.1 256 kb/s, SubRip eng sidecar embedded
|
||||
|
||||
Episode breakdown:
|
||||
|
||||
| Season | Files | Air era | Disney+ scheme |
|
||||
|---|---|---|---|
|
||||
| S08 | 13 | 2010–2011 (CC reboot) | "Neutopia" → "Reincarnation" |
|
||||
| S09 | 13 | 2012 | "The Bots and the Bees" → "Naturama" |
|
||||
| S10 | 13 | 2013 | "2-D Blacktop" → "Meanwhile" |
|
||||
| S11 | 10 | 2023 (Hulu revival) | "The Impossible Stream" → "All The Way Down" |
|
||||
|
||||
## Numbering scheme conflict (logged for future ops)
|
||||
|
||||
Source uses **Disney+ / production order** where the CC reboot runs are S08/S09/S10 and the 2023 Hulu revival is S11. Existing `Futurama (1999)` library was built under **TVDB Default / aired order** — those same eps live under S06 + S07 (S06 ends with "Reincarnation", S07 ends with "Meanwhile").
|
||||
|
||||
Imported per user direction: keep source's S08–S11 numbering as labelled. JF parser extracted the season number from the path (`Season 08/`) but TVDB couldn't match `S08–S11` against the library's existing series record → all 49 eps landed with `IndexNumber=null`, `Name="Futurama"`, no `ProviderIds`. Fix described below.
|
||||
|
||||
## Steps executed
|
||||
|
||||
1. **Pre-flight** — torrent download monitored (qBt pid 13686, magnet pinned only S08; auto-completed at 23:04). Confirmed file integrity post-completion: 49 of 49 mkvs intact, all 21:40 runtime for S08–S10, 24:00 runtime for S11 (Hulu revival).
|
||||
2. **Stage on onyx** — hardlinked into `/home/admin/staging-jelly/Futurama (1999)/Season {08,09,10,11}/` and renamed per playbook §1b: `Futurama (1999) - S<NN>E<MM> - <Episode Title>.mkv`. Titles read from each mkv's `format_tags=title`. Per playbook §1f: replaced `:` → ` - ` in "T.: The Terrestrial" → "T. - The Terrestrial".
|
||||
3. **rsync** — `rsync -a --no-owner --no-group /home/admin/staging-jelly/Futurama (1999)/ user@nullstone:/home/user/media/tv/Futurama (1999)/`. Transfer: 19.7 GB / 49 files / 12:17 min / exit 0.
|
||||
4. **Perms** — `chmod 644` files / `755` dirs. Final: `user user 4096 May 13 23:11 Season {08..11}`.
|
||||
5. **Scan** — `POST /ScheduledTasks/Running/7738148ffcd07979c7ceb148e06b3aed` → HTTP 204. Idle reached at 23:24:57.
|
||||
6. **Verify** — Per-season counts matched on-disk: S08=13, S09=13, S10=13, S11=10. **But:** every ep had `Name="Futurama"`, `IndexNumber` missing, `ProviderIds=[]`, no Primary image.
|
||||
7. **Series-level `FullRefresh&Recursive=true` fired** at 23:25 — no effect after 10 min. Plugin (Intro Skipper) had successfully analysed every file (logs show all 49 paths), so JF *had* file→episode bindings; TVDB just didn't yield S08–S11 metadata for this series record.
|
||||
8. **Manual lock per the LFP pattern** (memory `feedback_jellyfin_lex_fridman_se_lock`):
|
||||
- For each of 49 items: GET `/Users/<uid>/Items/<id>` → set `Name`, `IndexNumber`, `ParentIndexNumber` (from filename regex `S(\d{2})E(\d{2}) - (.+)\.mkv`), `LockData=true` → POST `/Items/<id>`.
|
||||
- Script: `fix_futurama_remote.py` (urllib via container IP `172.20.0.20:8096`).
|
||||
- Result: `fixed=49 errs=0`.
|
||||
|
||||
### Single-item verification (random sample)
|
||||
|
||||
```
|
||||
GET /Users/2ad8.../Items/026b01957306ce8172a1b74c3770993e
|
||||
Name: The Impossible Stream
|
||||
IndexNumber: 1
|
||||
ParentIndexNumber: 11
|
||||
LockData: True
|
||||
LockedFields: []
|
||||
```
|
||||
|
||||
### ffprobe excerpt (representative — S08E01)
|
||||
|
||||
```
|
||||
Container: mkv size=372458696 duration=1299.904s bitrate=2.29Mb/s
|
||||
Stream 0: hevc Main10 1920x1080
|
||||
Stream 1: eac3 6ch 5.1(side) 256kb/s
|
||||
Stream 2: subrip (embedded, eng)
|
||||
Stream 3: png 600x338 (chapter thumbnail)
|
||||
```
|
||||
|
||||
## Subtitle status
|
||||
|
||||
All 49 mkvs ship embedded SubRip eng. No external sidecars added. Library `tv` keeps internet-provider sub fetch enabled, so OpenSubtitles fills any gaps on first play. No STOPGAP-SUBS entry needed.
|
||||
|
||||
## Anything unusual
|
||||
|
||||
- **Numbering scheme drift between source and existing series record** — `IndexNumber` had to be set manually via the `/Items/<id>` POST + `LockData=true` route for all 49 eps. Future imports under the Disney+ scheme into a TVDB-default-numbered series will hit the same gap; reuse `fix_futurama_remote.py`.
|
||||
- **`jellyfin-stock` container has no python3** — `docker exec` python attempt failed (`exec: "python3": executable file not found in $PATH`). Workaround: run the script on the nullstone host and target the container's bridge IP (`172.20.0.20:8096`), which is reachable from the host.
|
||||
- **Series-level `FullRefresh&Recursive=true` is non-blocking and silent** — it returns 204 immediately but doesn't drive episode-level TVDB matching when the scheme mismatch is already locked in. Not relied on post-2026-05-13.
|
||||
- **Quality check ahead of import** — three sampled eps confirmed source bitrate (2.29 / 2.38 / 2.59 Mb/s) is slightly *below* the existing JF copies of the same content (2.31 / 2.44 / 3.22 Mb/s, BluRay-source HEVC). For overlapping content the original JF copies remain canonical; the S08–S11 import is alongside, not replacement.
|
||||
|
||||
## Verification checklist
|
||||
|
||||
- [x] Folder + filename match canonical pattern.
|
||||
- [x] Permissions `user:user`, 644/755.
|
||||
- [x] No `.eng.srt` sidecars (eng subs are embedded SubRip).
|
||||
- [x] `Scan Media Library` triggered via task id `7738148ffcd07979c7ceb148e06b3aed`, advanced to Idle.
|
||||
- [x] Per-season `/Shows/<id>/Episodes?Season=N` returns matching count and names.
|
||||
- [x] All 49 items `LockData=True` after manual fix.
|
||||
- [ ] Direct-play in client — not yet user-confirmed.
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# johnny-harris-assange-guilty-plea-20220510
|
||||
|
||||
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
|
||||
(container `jellyfin-stock`), **Education** library
|
||||
(`collectionType=movies`, internet providers disabled).
|
||||
|
||||
Channel `Johnny Harris/` folder already had 5 files before this run, so
|
||||
Jellyfin's single-file-folder caveat (`feedback_jellyfin_single_file_channel`)
|
||||
does not apply — file resolves with its own filename as title.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source:** YouTube — `https://www.youtube.com/watch?v=P6bVl47kdNk`
|
||||
- **Channel:** Johnny Harris
|
||||
- **Tool:** `yt-dlp` on onyx
|
||||
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b` → `--merge-output-format mp4` (source 2160p, capped to 1080p per playbook §1e)
|
||||
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — English user-uploaded subs embedded AND sidecar `.en.srt`
|
||||
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg`, Primary via Local Posters
|
||||
- **Staging path on onyx:** `/home/admin/staging-jelly/Johnny Harris/`
|
||||
|
||||
### Filename normalisation
|
||||
|
||||
Raw YouTube title:
|
||||
> `Why Julian Assange's guilty plea will change journalism forever`
|
||||
|
||||
Apostrophe is the smart quote `'` (U+2019). Playbook §1f passes smart quotes
|
||||
through unchanged — no rename needed. Final filename:
|
||||
|
||||
`Why Julian Assange's guilty plea will change journalism forever — 20220510.mp4`
|
||||
|
||||
## Target
|
||||
|
||||
- **Server:** `jellyfin-stock` on nullstone, `https://tv.s8n.ru`
|
||||
- **Library:** Education
|
||||
- **Path on host:** `/home/user/media/education/Johnny Harris/Why Julian Assange's guilty plea will change journalism forever — 20220510.mp4`
|
||||
- **Container view:** same under `/media/education/`
|
||||
- **Item ID:** `6d2f161823995d547795615245dbdf94`
|
||||
|
||||
### Sidecar files
|
||||
|
||||
| Kind | File |
|
||||
|---|---|
|
||||
| Media | `… — 20220510.mp4` (207,130,253 B, ~197 MiB) |
|
||||
| Subtitle | `… — 20220510.en.srt` (57,288 B) |
|
||||
| Thumbnail | `… — 20220510.jpg` (110,333 B) — Primary via Local Posters |
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| Education / Johnny Harris items | 5 | 6 | +1 |
|
||||
|
||||
## Stream summary
|
||||
|
||||
```
|
||||
Duration: 00:32:07.28, bitrate: 859 kb/s
|
||||
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 726 kb/s, 23.98 fps
|
||||
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
|
||||
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g)
|
||||
```
|
||||
|
||||
AV1 1080p23.98 + stereo AAC + embedded English mov_text + external .en.srt
|
||||
sidecar.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: yes — one English `mov_text` track.
|
||||
- External sidecar: yes — `.en.srt`.
|
||||
- Action: none.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`<Channel>/<Title> — <YYYYMMDD>.mp4`).
|
||||
- [x] Smart apostrophe preserved per §1f; no forbidden chars in path.
|
||||
- [x] Permissions `user:user` 644.
|
||||
- [x] Single `Scan Media Library` invocation indexed it on first pass
|
||||
(no re-trigger needed — folder pre-existing, not new).
|
||||
- [x] `/Items?searchTerm=Assange` returns single expected item with
|
||||
`ImageTags.Primary` present, `ProviderIds` empty (expected for
|
||||
Education library).
|
||||
- [x] `Name` resolved from filename — no folder-name fallback (≥2 files in
|
||||
channel folder).
|
||||
- [ ] Direct-play in mobile / Smart-TV client not exercised.
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
None — clean reference run. Validates that the single-file caveat
|
||||
(`feedback_jellyfin_single_file_channel`) is strictly tied to one-file
|
||||
channel folders; pre-populated folders parse correctly from filename.
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# johnny-harris-why-us-deporting-20251031
|
||||
|
||||
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
|
||||
(container `jellyfin-stock`), **Education** library
|
||||
(`collectionType=movies`, internet providers disabled).
|
||||
|
||||
Channel "Johnny Harris" folder already existed with 4 prior videos. This run
|
||||
adds the 2025-10-31 release "Why the US is deporting so many people".
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source:** YouTube — `https://www.youtube.com/watch?v=aDbtrdfYqBc`
|
||||
- **Channel:** Johnny Harris
|
||||
- **Tool:** `yt-dlp` on onyx
|
||||
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b` → `--merge-output-format mp4` (source available up to 2160p, capped to 1080p per playbook §1e)
|
||||
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — user-uploaded English subs present, embedded into mp4 AND written as sidecar `.en.srt`
|
||||
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg` used as Primary by Local Posters plugin
|
||||
- **Staging path on onyx:** `/home/admin/staging-jelly/Johnny Harris/`
|
||||
|
||||
## Target
|
||||
|
||||
- **Server:** `jellyfin-stock` on nullstone, public URL `https://tv.s8n.ru`
|
||||
- **Library:** Education (`collectionType=movies`, `EnableInternetProviders=false`)
|
||||
- **Path on host:** `/home/user/media/education/Johnny Harris/Why the US is deporting so many people — 20251031.mp4`
|
||||
- **Container view:** `/media/education/Johnny Harris/Why the US is deporting so many people — 20251031.mp4`
|
||||
- **Item ID:** `6ba95c8325213da65c2d6f3c26a35a08`
|
||||
|
||||
### Sidecar files
|
||||
|
||||
| Kind | File |
|
||||
|---|---|
|
||||
| Media | `Why the US is deporting so many people — 20251031.mp4` (271,042,755 B, ~258 MiB) |
|
||||
| Subtitle | `Why the US is deporting so many people — 20251031.en.srt` (83,161 B) |
|
||||
| Thumbnail | `Why the US is deporting so many people — 20251031.jpg` (70,137 B) — Primary image via Local Posters |
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| Education / Johnny Harris items | 4 | 5 | +1 |
|
||||
|
||||
## Stream summary
|
||||
|
||||
```
|
||||
Duration: 00:45:26.32, bitrate: 795 kb/s
|
||||
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 662 kb/s, 23.98 fps
|
||||
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 128 kb/s
|
||||
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g)
|
||||
```
|
||||
|
||||
AV1 1080p at ~0.66 Mb/s + stereo AAC + embedded English mov_text subs.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: yes — one English `mov_text` track from yt-dlp `--embed-subs`.
|
||||
- External sidecar: yes — `.en.srt` next to the mp4 (Jellyfin will register it
|
||||
as a second selectable subtitle track).
|
||||
- Action: none. Plain English, no SDH/MT/AI tag per ARRFLIX subtitle style.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`<Channel>/<Title> — <YYYYMMDD>.mp4`, date as suffix).
|
||||
- [x] No forbidden chars in path.
|
||||
- [x] Permissions `user:user` 644 / 755 (chmod safety net run server-side).
|
||||
- [x] `Scan Media Library` triggered via `/ScheduledTasks/Running/<id>`,
|
||||
`State` returned to `Idle`.
|
||||
- [x] `/Items?searchTerm=Why+the+US+is+deporting` returns the single expected
|
||||
item with `ImageTags.Primary` present, `ProviderIds` empty (expected for
|
||||
Education library).
|
||||
- [x] Direct-play in client browser (AV1 supported by Chromium >= 90).
|
||||
- [ ] Mobile / Smart-TV direct-play not exercised.
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
- Source upload available in 2160p AV1; 1080p cap per playbook §1e mandatory
|
||||
for long-form (~45min) content.
|
||||
- No metadata refresh needed — Local Posters picked up `<basename>.jpg` as
|
||||
Primary on first scan; no Screen Grabber fallback.
|
||||
- Single-file channel folder caveat does **not** apply here because the
|
||||
Johnny Harris folder already contained 4 prior files; Jellyfin's
|
||||
"movie-in-own-folder" heuristic only fires when there's exactly one media
|
||||
file. See `the-guardian-snowden-2013-20130709.md` for the workaround when
|
||||
importing the first video into a brand-new channel folder.
|
||||
151
playbooks/import-media/runs/lex-fridman-podcast-s01-yt-import.md
Normal file
151
playbooks/import-media/runs/lex-fridman-podcast-s01-yt-import.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# lex-fridman-podcast-s01-yt-import
|
||||
|
||||
Second YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru` (container
|
||||
`jellyfin-stock`), this time targeting the **Podcasts** library. Five episodes
|
||||
of the Lex Fridman Podcast — treated as one Series (`Lex Fridman Podcast`) on
|
||||
Season 01, with the podcast episode number used directly as the episode index
|
||||
(E400, E461, E478, E479, E481).
|
||||
|
||||
Independent from arrflix prod (`arrflix.s8n.ru`) and arrflix dev. Stock
|
||||
Jellyfin's Podcasts library has `EnableInternetProviders=false` — files land
|
||||
with filename/folder-only metadata. **No TMDb/TVDB matching is expected or
|
||||
attempted.** Mirrors the pattern set by `benn-jordan-s01-yt-import.md`
|
||||
(commit `6e336d1`).
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source:** YouTube channel "Lex Fridman" (podcast back catalogue picks)
|
||||
- **Tool:** `yt-dlp` 2026.03.17 on onyx
|
||||
- **Format selector (1080p cap):** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b` → `--merge-output-format mp4`
|
||||
- **Subs:** `--write-subs --sub-langs "en.*" --embed-subs --convert-subs srt` — user-uploaded en subs embedded as `mov_text` when present (4/5 eps); E478 had no en track on YouTube.
|
||||
- **Staging path on onyx:** `/home/admin/staging-jelly/Lex Fridman Podcast/Season 01/`
|
||||
- **Parallel downloads:** 5 jobs spawned simultaneously, master wrapper `wait`-blocked until ALL exited 0 before rsync (lesson from prior run — never race rsync against in-flight downloads).
|
||||
|
||||
### Source URLs
|
||||
|
||||
| Episode | Video ID | URL |
|
||||
|---|---|---|
|
||||
| S01E400 | JN3KPFbWCy8 | https://www.youtube.com/watch?v=JN3KPFbWCy8 |
|
||||
| S01E461 | tNZnLkRBYA8 | https://www.youtube.com/watch?v=tNZnLkRBYA8 |
|
||||
| S01E478 | jdCKiEJpwf4 | https://www.youtube.com/watch?v=jdCKiEJpwf4 |
|
||||
| S01E479 | HsLgZzgpz9Y | https://www.youtube.com/watch?v=HsLgZzgpz9Y |
|
||||
| S01E481 | SvKv7D4pBjE | https://www.youtube.com/watch?v=SvKv7D4pBjE |
|
||||
|
||||
Original YouTube titles had ` | Lex Fridman Podcast #XXX` suffix and a
|
||||
`Guest:` colon — stripped/replaced before filename construction per
|
||||
playbook filename rules (no forbidden chars `< > : " / \ | ? *`). Ampersand,
|
||||
comma, apostrophe, hyphen all preserved.
|
||||
|
||||
## Target
|
||||
|
||||
- **Server:** `jellyfin-stock` (container) on nullstone, exposed at `https://tv.s8n.ru`
|
||||
- **Library:** Podcasts (tvshows-type, internet providers disabled)
|
||||
- **Path on host:** `/home/user/media/podcasts/Lex Fridman Podcast/Season 01/`
|
||||
- **Container view:** `/media/podcasts/Lex Fridman Podcast/Season 01/`
|
||||
- **Series Item ID:** `6c01ab0084d87b94c124948f64f87c15`
|
||||
- **Season Item ID:** `67d2aaba01fe73f2ba90e36514823632`
|
||||
|
||||
### Per-episode landing
|
||||
|
||||
| Episode | File size | Duration (spec) | Duration (Jellyfin) | Item ID |
|
||||
|---|---:|---:|---:|---|
|
||||
| S01E400 — Elon Musk - War, AI, Aliens, Politics, Physics, Video Games, and Humanity | 419,097,052 B (~400 MiB) | 8206 s | 8206 s | `5266b338705003d6fd04e315a01cd7fe` |
|
||||
| S01E461 — ThePrimeagen - Programming, AI, ADHD, Productivity, Addiction, and God | 1,196,404,821 B (~1.11 GiB) | 19208 s | 19207 s | `b68e7628784ebdfafa21c3412bcb31f0` |
|
||||
| S01E478 — Scott Horton - The Case Against War and the Military Industrial Complex | 1,830,927,069 B (~1.70 GiB) | 37591 s | 37590 s | `9baab6a35e3c0f32f4776e9aa379745d` |
|
||||
| S01E479 — Dave Plummer - Programming, Autism, and Old-School Microsoft Stories | 583,179,948 B (~556 MiB) | 6628 s | 6628 s | `f33bf1d068c3c4771c8744f655256829` |
|
||||
| S01E481 — Norman Ohler - Hitler, Nazis, Drugs, WW2, Blitzkrieg, LSD, MKUltra & CIA | 933,193,939 B (~890 MiB) | 15944 s | 15944 s | `b5946af6a55919391b227c7893a73059` |
|
||||
|
||||
Total on disk ~4.74 GB across 5 mp4s. The 1080p cap kept the 10.4-hour E478
|
||||
to 1.7 GB — at 4K this would have ballooned past 50 GB.
|
||||
|
||||
Jellyfin's ffprobe is off by 1 s on E461/E478 (rounding-down vs YouTube's
|
||||
declared seconds) — within tolerance, no correction needed.
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| SeriesCount (Podcasts) | 0 | 1 | +1 |
|
||||
| EpisodeCount (Podcasts) | 0 | 5 | +5 |
|
||||
|
||||
(First import into the Podcasts library; pre-state empty.)
|
||||
|
||||
## Stream sample (S01E479)
|
||||
|
||||
```
|
||||
major_brand : isom
|
||||
Duration: 01:50:28.48, start: 0.000000, bitrate: 703 kb/s
|
||||
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main) (av01 / 0x31307661), yuv420p(tv, bt709), 1920x1080, 568 kb/s, 30 fps
|
||||
Stream #0:1[0x2](eng): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 127 kb/s
|
||||
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g / 0x67337874), 0 kb/s
|
||||
```
|
||||
|
||||
AV1 1080p30 ~568 kb/s + AAC stereo ~127 kb/s + embedded `mov_text` en subs.
|
||||
Source is YouTube's best 1080p mp4/m4a combo. AV1 direct-play requires a
|
||||
recent client (Chromium ≥ 90, Firefox ≥ 100, Apple Silicon Safari, Android
|
||||
12+, modern smart-TVs); otherwise `jellyfin-stock` will CPU-transcode (no GPU
|
||||
mount per SYSTEM.md).
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded `mov_text` (en): **yes** for E400 / E461 / E479 / E481 (user-uploaded en track present on the YouTube upload — yt-dlp embedded via `--embed-subs`).
|
||||
- E478: no en track available on YouTube — no embedded sub. Player will fall back to no subs unless auto-CC sidecar is fetched later (`--write-auto-subs --sub-langs "en.*"`).
|
||||
- External sidecar: none.
|
||||
- Action: leave as-is for now. If E478 subs become required, re-fetch auto-CC and drop a `.eng.srt` next to the mp4 per `playbooks/subtitles/`.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`Lex Fridman Podcast/Season 01/Lex Fridman Podcast - S01E<NNN> - <Title>.mp4`)
|
||||
- [x] Permissions `user:user` 644 / 755 on nullstone
|
||||
- [x] `Scan Media Library` task triggered via `/ScheduledTasks/Running/$SCAN_ID` (HTTP 204) — completed at 2026-05-11T14:43:12Z
|
||||
- [x] **Note:** initial scan created Series + Season stubs but ChildCount=0. A follow-up `/Items/$SERIES_ID/Refresh?MetadataRefreshMode=FullRefresh&Recursive=true` (HTTP 204) was required to actually pull the 5 episodes into the index. This was *not* required in the benn-jordan run — possibly because Lex's filenames include forbidden-looking characters (`,` `&` `-`) and Jellyfin's series-stub-first heuristic is slower to reconcile when the discovery probe is racing the scan. Documented here so the next operator knows the second-pass refresh is sometimes load-bearing.
|
||||
- [x] Per-series query `/Shows/$SERIES_ID/Episodes?Season=1` returns 5 episodes with correct durations
|
||||
- [x] No `/Items/Counts` reliance — used `/Shows/<id>/Episodes` as authoritative
|
||||
- [n/a] `ProviderIds` populated — **expected empty**, Podcasts library has internet providers OFF
|
||||
- [x] `ImageTags.Primary` populated on all 5 — Jellyfin extracted thumbnail from mp4 itself
|
||||
|
||||
### Scan task
|
||||
|
||||
- **Task ID:** `7738148ffcd07979c7ceb148e06b3aed`
|
||||
- **POST result:** HTTP 204
|
||||
- **StartTime (initial scan):** `2026-05-11T14:43:01.031Z`
|
||||
- **EndTime (initial scan):** `2026-05-11T14:43:12.285Z` (11 s)
|
||||
- **Follow-up series refresh:** POST `/Items/$SERIES_ID/Refresh` returned HTTP 204; episodes appeared in season within ~3 s.
|
||||
- **State after run:** `Idle`
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
- Stock Jellyfin's Podcasts library is configured `tvshows`-type with
|
||||
`EnableInternetProviders=false`. This matches the Educational library set
|
||||
up for Benn Jordan — same pattern, different folder. **Do not try to
|
||||
TMDb-identify Lex Fridman Podcast episodes; the Podcasts library is
|
||||
deliberately offline.**
|
||||
- Used podcast episode number as the season-1 episode index. E400/E461/E478/E479/E481
|
||||
is consciously sparse — Jellyfin handles non-contiguous episode numbers fine,
|
||||
and using the canonical podcast number means there's no ambiguity when an
|
||||
operator looks at the UI and matches "Lex #481" to a file.
|
||||
- All 5 downloads ran in parallel from onyx via a wrapper script
|
||||
(`/tmp/lex-download.sh`) which `wait`-blocked on every job PID before
|
||||
exiting. The wrapper's exit code (0) gated the rsync step — addressing the
|
||||
"rsync raced partial downloads" failure mode from a prior YouTube import.
|
||||
- E478 is 10.4 hours (`Scott Horton - The Case Against War and the Military
|
||||
Industrial Complex`). Capped at 1080p it weighs in at 1.7 GB / ~590 kb/s
|
||||
total. At 4K it would have exceeded 50 GB and absolutely buried disk.
|
||||
The format selector `bv*[height<=1080]` is now the standing rule for any
|
||||
podcast-style long-form import.
|
||||
- rsync ran at ~61 MB/s onyx → nullstone over the 1G LAN (4.7 GB in ~80 s).
|
||||
No `--info=progress2` surprises; resumable on disconnect via rsync defaults.
|
||||
- Source staging dir on onyx (`/home/admin/staging-jelly/Lex Fridman Podcast/`)
|
||||
is intentionally left in place — do not delete until owner confirms
|
||||
playback.
|
||||
|
||||
## Operator action
|
||||
|
||||
1. Open `https://tv.s8n.ru` → Podcasts library → confirm "Lex Fridman Podcast"
|
||||
series shows 5 episodes (numbered 400 / 461 / 478 / 479 / 481).
|
||||
2. Play any episode → confirm direct-play on a modern AV1-capable client (no
|
||||
transcode line in `docker logs jellyfin-stock`). On older clients expect
|
||||
CPU transcode.
|
||||
3. Optional: upload custom series poster + per-episode artwork via the
|
||||
Jellyfin web UI (no TMDb fallback, so artwork is manual or absent).
|
||||
4. Source dir on onyx retained per cleanup policy.
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# lex-fridman-podcast-s01e491-openclaw
|
||||
|
||||
Single-episode YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
|
||||
(container `jellyfin-stock`), **Podcasts** library
|
||||
(`collectionType=tvshows`, internet providers disabled).
|
||||
|
||||
Extends the existing `Lex Fridman Podcast` Season 01 series — sparse numbering
|
||||
already in use (400 / 461 / 478 / 479 / 481). Adds episode 491.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source:** YouTube — `https://www.youtube.com/watch?v=YFjfBk8HI5o`
|
||||
- **Channel / Series:** Lex Fridman Podcast
|
||||
- **Episode:** #491 "OpenClaw: The Viral AI Agent that Broke the Internet — Peter Steinberger"
|
||||
- **Tool:** `yt-dlp` on onyx
|
||||
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b` → `--merge-output-format mp4` (source native 1080p)
|
||||
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — user-uploaded English subs embedded AND sidecar `.en.srt`
|
||||
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg`, Primary via Local Posters
|
||||
- **yt-dlp output template:** `-o "Lex Fridman Podcast - S01E491 - OpenClaw - The Viral AI Agent that Broke the Internet - Peter Steinberger.%(ext)s"` (downloaded straight to canonical filename — no post-download rename needed)
|
||||
- **Staging path on onyx:** `/home/admin/staging-jelly/Lex Fridman Podcast/Season 01/`
|
||||
|
||||
### Filename normalisation
|
||||
|
||||
Raw YouTube title:
|
||||
> `OpenClaw: The Viral AI Agent that Broke the Internet - Peter Steinberger | Lex Fridman Podcast #491`
|
||||
|
||||
Applied playbook §1f rules:
|
||||
- Dropped suffix ` | Lex Fridman Podcast #491` (redundant with `Lex Fridman Podcast - S01E491 -` prefix).
|
||||
- Replaced ASCII `:` after `OpenClaw` with ` - ` (forbidden char).
|
||||
- Pipe `|` not present in episode-title portion after suffix drop.
|
||||
|
||||
Final filename (per playbook §1c numbered-podcast pattern):
|
||||
`Lex Fridman Podcast - S01E491 - OpenClaw - The Viral AI Agent that Broke the Internet - Peter Steinberger.mp4`
|
||||
|
||||
## Target
|
||||
|
||||
- **Server:** `jellyfin-stock` on nullstone, `https://tv.s8n.ru`
|
||||
- **Library:** Podcasts (`collectionType=tvshows`, `EnableInternetProviders=false`)
|
||||
- **Series Item ID:** `6c01ab0084d87b94c124948f64f87c15`
|
||||
- **Season Item ID:** `67d2aaba01fe73f2ba90e36514823632`
|
||||
- **Episode Item ID:** `fbeeffb256a04f103987f9b22d0bd442`
|
||||
- **Path on host:** `/home/user/media/podcasts/Lex Fridman Podcast/Season 01/Lex Fridman Podcast - S01E491 - OpenClaw - The Viral AI Agent that Broke the Internet - Peter Steinberger.mp4`
|
||||
|
||||
### Sidecar files
|
||||
|
||||
| Kind | File |
|
||||
|---|---|
|
||||
| Media | `… - S01E491 - … .mp4` (749,120,258 B, ~715 MiB) |
|
||||
| Subtitle | `… - S01E491 - … .en.srt` (270,915 B) |
|
||||
| Thumbnail | `… - S01E491 - … .jpg` (64,605 B) — Primary via Local Posters |
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| Lex Fridman Podcast / Season 01 episodes | 5 | 6 | +1 |
|
||||
|
||||
## Stream summary
|
||||
|
||||
```
|
||||
Duration: 03:15:51.67, bitrate: 509 kb/s
|
||||
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 375 kb/s, 29.97 fps
|
||||
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
|
||||
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g)
|
||||
```
|
||||
|
||||
AV1 1080p29.97 + stereo AAC + embedded English mov_text + external .en.srt
|
||||
sidecar. 3:15:52 runtime, ~715 MiB — well under the 1080p cap budget for
|
||||
long-form content.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: yes — one English `mov_text` track from `--embed-subs`.
|
||||
- External sidecar: yes — `.en.srt`.
|
||||
- Action: none. WhisperX rebuild not required (channel-published subs trusted
|
||||
for podcast transcripts; per `feedback_subtitle_accuracy_priority` only
|
||||
auto-CC is rejected — these are author-provided).
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`Lex Fridman Podcast - S01E491 - <Title>.mp4` per playbook §1c).
|
||||
- [x] No forbidden chars in path.
|
||||
- [x] Permissions `user:user` 644.
|
||||
- [x] `Scan Media Library` triggered, `State=Idle`, episode appeared in
|
||||
`/Shows/<id>/Episodes?Season=1`.
|
||||
- [x] `/Items?searchTerm=OpenClaw` returns the expected single Episode item.
|
||||
- [x] `ImageTags.Primary` present (Local Posters from sidecar `.jpg`).
|
||||
- [x] `Type=Episode`, `SeriesId` / `SeasonId` correctly attached.
|
||||
- [x] `ParentIndexNumber=1`, `IndexNumber=491` populated (see Notes — required
|
||||
manual override, JF scan did not parse SxxEyy from filename despite
|
||||
the filename containing `S01E491`).
|
||||
- [x] `LockData=true` set so future series refresh cannot revert the SE.
|
||||
- [ ] Direct-play in mobile / Smart-TV client not exercised.
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
### JF MovieResolver did not parse `S01E491` from filename — manual SE override required
|
||||
|
||||
After the `Scan Media Library` pass, the episode resolved as `Type=Episode`
|
||||
attached to the correct Series + Season, but `ParentIndexNumber` and
|
||||
`IndexNumber` were both `null`. The other 5 episodes in Season 01 (E400 /
|
||||
E461 / E478 / E479 / E481) all have the same `Lex Fridman Podcast - S<NN>E<NN>
|
||||
- <Title>.mp4` pattern and parsed correctly — root cause unclear.
|
||||
|
||||
Tried fixes that did **not** work:
|
||||
1. Series-level `POST /Items/<seriesId>/Refresh?MetadataRefreshMode=FullRefresh&Recursive=true` → state did not change.
|
||||
2. Item-level `POST /Items/<episodeId>/Refresh?MetadataRefreshMode=FullRefresh&ReplaceAllMetadata=true` → state did not change.
|
||||
|
||||
Working fix — direct API override:
|
||||
|
||||
```bash
|
||||
EP_ID=fbeeffb256a04f103987f9b22d0bd442
|
||||
curl -sf -H "X-Emby-Token: $TOK" \
|
||||
"$SERVER_URL/Users/$USER_ID/Items/$EP_ID" \
|
||||
| jq '.ParentIndexNumber = 1 | .IndexNumber = 491 | .LockData = true' \
|
||||
| curl -sf -X POST -H "X-Emby-Token: $TOK" -H 'Content-Type: application/json' \
|
||||
--data @- "$SERVER_URL/Items/$EP_ID" # → HTTP 204
|
||||
```
|
||||
|
||||
Inspecting the 5 known-good episodes, **all of them** already have
|
||||
`LockData=true`, so this is the established pattern for the `Lex Fridman
|
||||
Podcast` series — every new episode appears to need the override. Generalise
|
||||
to a post-import step in the playbook §1c "numbered podcast" section.
|
||||
|
||||
Possible upstream cause: episode-resolver in JF 10.11.8 may bail when the
|
||||
filename contains additional hyphen-separated segments that resemble more
|
||||
SxxEyy tokens. Investigate after second occurrence.
|
||||
63
playbooks/import-media/runs/lilo-stitch-2002.md
Normal file
63
playbooks/import-media/runs/lilo-stitch-2002.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# lilo-stitch-2002
|
||||
|
||||
First run of `playbooks/import-media/` v1.0.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source path on onyx:** `/home/admin/Downloads/Lilo & Stitch (2002) (1080p BluRay x265 HEVC 10bit EAC3 5.1 YOGI)/Lilo & Stitch (2002) (1080p BluRay x265 YOGI).mkv`
|
||||
- **Release group:** YOGI
|
||||
- **Quality:** 1080p BluRay HEVC 10-bit
|
||||
- **Audio:** EAC3 5.1 English
|
||||
|
||||
## Target
|
||||
|
||||
- **Library:** movies
|
||||
- **Path:** `/home/user/media/movies/Lilo & Stitch (2002)/Lilo & Stitch (2002).mkv`
|
||||
- **Container view:** `/media/movies/Lilo & Stitch (2002)/Lilo & Stitch (2002).mkv`
|
||||
- **Item ID:** `c2f4aff133c1b9631500fadf293b0b2f`
|
||||
- **TMDb:** `11544`
|
||||
- **IMDb:** `tt0275847`
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| MovieCount | 3 | 4 | +1 |
|
||||
|
||||
## Stream summary
|
||||
|
||||
```
|
||||
Duration: 01:25:22.18, bitrate: 3698 kb/s
|
||||
Stream #0:0: Video: hevc (Main 10), yuv420p10le(tv, bt709), 1816x1080, 23.98 fps
|
||||
Stream #0:1(eng): Audio: eac3, 48000 Hz, 5.1(side), 640 kb/s
|
||||
Stream #0:2(eng): Subtitle: hdmv_pgs_subtitle (pgssub)
|
||||
Stream #0:3(eng): Subtitle: hdmv_pgs_subtitle (pgssub), 1920x1080
|
||||
```
|
||||
|
||||
Color tagged BT.709 (SDR) — no tonemap path needed. Direct-play-friendly HEVC.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: yes — 2× English PGS (image-based, burnt-in style)
|
||||
- External sidecar: none yet
|
||||
- Action: none for now. PGS works on most clients via server burn-in. If text subs preferred, run `playbooks/subtitles/` later.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`Lilo & Stitch (2002)/Lilo & Stitch (2002).mkv`)
|
||||
- [x] Permissions `user:user` 644 (file) / 755 (dir) — verified via `ls -la` post-rsync
|
||||
- [ ] LibraryMonitor auto-fired — DID NOT trigger on this import (file landed but no log line). Forced manual `POST /Library/Refresh` returned 204 → ffprobe ran within seconds → MovieCount bumped. **Possible cause:** rsync over many seconds may break the inotify watch's debounce; bind-mount FS event delivery from host into container can be flaky on userns-remap setups. Manual refresh always works. Flagged for v1.1 of playbook to recommend always running manual refresh after rsync.
|
||||
- [x] `Items/Counts.MovieCount` bumped 3 → 4
|
||||
- [x] TMDb match: `11544` populated
|
||||
- [x] Artwork: PrimaryImage `15330b2e...` + 1 backdrop fetched
|
||||
- [x] Direct-play candidate (HEVC 10-bit, BT.709, EAC3 5.1)
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
- LibraryMonitor didn't auto-pick the new file — had to force `POST /Library/Refresh`. Updated the playbook to make this an unconditional step rather than "optional fallback".
|
||||
- Filename didn't need NFO override — TMDb matched correctly on first try via folder + year.
|
||||
- Source download at `/home/admin/Downloads/Lilo & Stitch (2002) (1080p BluRay x265 HEVC 10bit EAC3 5.1 YOGI)/` retained per `ADMIN-GUIDE.md:74` (don't delete until confirmed playing in app).
|
||||
|
||||
## Operator action
|
||||
|
||||
User to verify in browser: `https://arrflix.s8n.ru` → search "Lilo" → confirm artwork + Play. After that, source download on laptop can be deleted.
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
# more-perfect-union-palantir-20250417
|
||||
|
||||
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
|
||||
(container `jellyfin-stock`), **Education** library
|
||||
(`collectionType=movies`, internet providers disabled, fresh path).
|
||||
|
||||
First import for channel `More Perfect Union` — creates the channel folder
|
||||
under `/media/education/`.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source:** YouTube — `https://www.youtube.com/watch?v=DZ95Gmvg_D4`
|
||||
- **Channel:** More Perfect Union
|
||||
- **Title:** "I Worked At Palantir: The Tech Company Reshaping Reality"
|
||||
- **Upload date:** 2025-04-17
|
||||
- **Duration:** 16:31
|
||||
- **Tool:** `yt-dlp` on onyx
|
||||
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b` → `--merge-output-format mp4` (source native 1080p AV1+AAC)
|
||||
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — author-provided English subs embedded AND sidecar `.en.srt`
|
||||
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg`, Primary via Local Posters
|
||||
- **yt-dlp output template:** `-o "%(title)s — %(upload_date)s.%(ext)s"`
|
||||
- **Staging path on onyx:** `/home/admin/staging-jelly/More Perfect Union/`
|
||||
|
||||
### Filename normalisation
|
||||
|
||||
Raw yt-dlp output (with fullwidth colon substitute):
|
||||
`I Worked At Palantir: The Tech Company Reshaping Reality — 20250417.mp4`
|
||||
|
||||
Applied playbook §1f rules:
|
||||
- Replaced U+FF1A FULLWIDTH COLON (yt-dlp's safe substitute for `:`) with
|
||||
` - `. Playbook §1f forbids ASCII `:`; the fullwidth fallback is
|
||||
cosmetically ugly and breaks search.
|
||||
|
||||
Final filename (per playbook §1d Education pattern — date as suffix, em-dash):
|
||||
`I Worked At Palantir - The Tech Company Reshaping Reality — 20250417.mp4`
|
||||
|
||||
## Target
|
||||
|
||||
- **Server:** `jellyfin-stock` on nullstone, `https://tv.s8n.ru`
|
||||
- **Library:** Education (`collectionType=movies`, `EnableInternetProviders=false`)
|
||||
- **Library Item ID:** `484cf52875118e03bd7effc72621bec0`
|
||||
- **Movie Item ID:** `d9127cf53df5f81565bc217305179962`
|
||||
- **Path on host:** `/home/user/media/education/More Perfect Union/I Worked At Palantir - The Tech Company Reshaping Reality — 20250417.mp4`
|
||||
|
||||
### Sidecar files
|
||||
|
||||
| Kind | File |
|
||||
|---|---|
|
||||
| Media | `… — 20250417.mp4` (89,599,403 B, ~85 MiB) |
|
||||
| Subtitle | `… — 20250417.en.srt` (22,955 B) |
|
||||
| Thumbnail | `… — 20250417.jpg` (56,398 B) — Primary via Local Posters |
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| Education library items | 11 | 12 | +1 |
|
||||
| `More Perfect Union/` channel folder | — | created | new channel |
|
||||
|
||||
## Stream summary
|
||||
|
||||
```
|
||||
Container: mp4 Size: 85.5 MiB Duration: 16:31
|
||||
Video av1 und 1080p AV1 SDR
|
||||
Audio aac eng English AAC stereo
|
||||
Subtitle mov_text eng English — Default — MOV_TEXT (embedded)
|
||||
Subtitle subrip eng English — SUBRIP — External (.en.srt)
|
||||
```
|
||||
|
||||
AV1 1080p source — direct-play in any AV1-capable client (Chromium 100+,
|
||||
recent VLC, mpv).
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: yes — one English `mov_text` track from `--embed-subs`.
|
||||
- External sidecar: yes — `.en.srt`.
|
||||
- Source: channel-published (author-provided) — yt-dlp `--sub-langs 'en'`
|
||||
fetches manual subs only, never auto-CC.
|
||||
- Action: none. No WhisperX rebuild needed.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (playbook §1d — date suffix, em-dash, no Season dir).
|
||||
- [x] No forbidden chars in path.
|
||||
- [x] Permissions `user:user` 644 / 755.
|
||||
- [x] `Scan Media Library` triggered via `/ScheduledTasks/Running/<id>`,
|
||||
`LastExecutionResult.Status=Completed`.
|
||||
- [x] Item resolved as `Type=Movie` in Education library.
|
||||
- [x] `ImageTags.Primary` present (Local Posters from sidecar `.jpg`).
|
||||
- [x] Embedded + external subtitle streams both registered.
|
||||
- [x] `LockData=true` set after manual Name override (see Notes).
|
||||
- [ ] Direct-play in mobile / Smart-TV client not exercised.
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
### JF single-file-in-channel-folder leaked folder name as title
|
||||
|
||||
First import for a brand-new channel folder produces exactly **one** file in
|
||||
`/media/education/More Perfect Union/`. JF's movie resolver applied the
|
||||
folder-name-as-title heuristic and registered the item as
|
||||
`Name="More Perfect Union"` — wiping the actual episode title.
|
||||
|
||||
This matches the documented pattern in
|
||||
`feedback_jellyfin_single_file_channel`: movie-in-own-folder → folder name
|
||||
wins. Fix: PUT `/Items/<id>` with corrected `Name` + `LockData=true` so
|
||||
future scans don't revert it.
|
||||
|
||||
Working fix:
|
||||
|
||||
```bash
|
||||
TOK=<admin>
|
||||
ITEM=d9127cf53df5f81565bc217305179962
|
||||
USER_ID=2ad8033c4f97486788d4a4b4915b9c0f
|
||||
|
||||
curl -sf -H "X-Emby-Token: $TOK" \
|
||||
"$SERVER_URL/Users/$USER_ID/Items/$ITEM" \
|
||||
| jq '.Name = "I Worked At Palantir - The Tech Company Reshaping Reality — 20250417" | .LockData = true' \
|
||||
| curl -sf -X POST -H "X-Emby-Token: $TOK" -H 'Content-Type: application/json' \
|
||||
--data @- "$SERVER_URL/Items/$ITEM" # → HTTP 204
|
||||
```
|
||||
|
||||
Generalises to all single-file YouTube imports: drop a second file into the
|
||||
channel folder before scanning, OR accept the post-import `Name`+`LockData`
|
||||
override. Once the channel has ≥2 files, JF parses filenames correctly.
|
||||
154
playbooks/import-media/runs/multi-import-20260514.md
Normal file
154
playbooks/import-media/runs/multi-import-20260514.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Multi-import + nullstone cleanup — 2026-05-14
|
||||
|
||||
**Operator:** s8n
|
||||
**Libraries touched:** `tv`, `movies`
|
||||
|
||||
This run bundles three things into one session-level log:
|
||||
|
||||
1. Cross-ref + delete of three Futurama duplicate dirs on onyx
|
||||
2. Import of The Inbetweeners (2008) — TV + 2 movies
|
||||
3. Delete of Mandalorian + Obi-Wan Kenobi from JF to free space
|
||||
4. Import of Mr. Robot (2015) + Kim Possible (2002) — TV + 2 KP movies
|
||||
5. Skipped: Rick and Morty S02-S03 + Mr Robot (per the original conditional rule — see below)
|
||||
|
||||
---
|
||||
|
||||
## 1. Futurama cleanup (3 duplicate sources deleted from onyx)
|
||||
|
||||
Cross-referenced each Futurama dir in `~/Downloads` against the existing `Futurama (1999)` series in JF. **All three were duplicates of content already on JF.** Stream probes confirmed.
|
||||
|
||||
| Source | Size | Verdict | Action |
|
||||
|---|---|---|---|
|
||||
| `Futurama (1999) Season 1-7 S01-S07 + Extras (Mixed x265 HEVC 10bit AAC 5.1 RCVR) REPACK` | 48G | 480p DVD HEVC — **same as JF S01-S07** (706x480 hevc Main10 + AC3 192kb/s, byte-for-byte stream match) | `rm -rf` |
|
||||
| `Futurama (1999) Season 8 S08 (1080p DSNP WEB-DL x265 HEVC 10bit EAC3 5.1 t3nzin)` | 3.9G | Hulu revival 10-ep set — same content as today's earlier JF S11 import | `rm -rf` |
|
||||
| `Futurama Season 1-11 Colection 1080p WEBDL` | 24G | DSNP S08-S11 set — same source as today's earlier JF S08-S11 import | `rm -rf` |
|
||||
|
||||
**Freed on onyx Downloads:** ~76G. qBt fastresume + .torrent records also removed for the 3 hashes (`471bb22...`, `582b5d7...`, `e4b2918...`) — qBt was throwing `file_open ... No such file or directory` errors and pausing those torrents on missing files.
|
||||
|
||||
## 2. The Inbetweeners (2008)
|
||||
|
||||
**Source:** `~/Downloads/The Inbetweeners 2008 S01-S03 Complete 1080p WEB-DL HEVC x265 BONE/` (8.3G)
|
||||
**Target series:** `/home/user/media/tv/The Inbetweeners (2008)/` (new in JF)
|
||||
**Target movies:** `/home/user/media/movies/The Inbetweeners Movie (2011)/` + `/home/user/media/movies/The Inbetweeners 2 (2014)/`
|
||||
|
||||
Stream profile: HEVC Main10 1080p, AAC stereo ~1.76 Mb/s, SubRip eng embedded.
|
||||
|
||||
Renamed per playbook §1b: `The Inbetweeners (2008) - S<NN>E<MM> - <Episode Title>.mkv` (titles parsed from torrent filenames).
|
||||
|
||||
| Season | Eps |
|
||||
|---|---|
|
||||
| S01 | 6 |
|
||||
| S02 | 6 |
|
||||
| S03 | 6 |
|
||||
|
||||
Plus two movies: `The Inbetweeners Movie (2011)` and `The Inbetweeners 2 (2014)` (BluRay HEVC 5.1).
|
||||
|
||||
rsync: 5.8G TV + 3.0G movies / exit 0.
|
||||
|
||||
Verify: 18 episodes + 2 movies indexed. JF auto-replaced my filename-derived titles with TVDB canonical (e.g. `Girlfriend` → `Will Gets a Girlfriend`). No manual LockData step needed.
|
||||
|
||||
## 3. Delete Mandalorian + Obi-Wan Kenobi from JF
|
||||
|
||||
User-requested storage cleanup. Deleted from nullstone:
|
||||
|
||||
| Path | Size freed |
|
||||
|---|---|
|
||||
| `/home/user/media/tv/The Mandalorian (2019)/` | 45G |
|
||||
| `/home/user/media/tv/Obi-Wan Kenobi (2022)/` | 16G |
|
||||
|
||||
`/home` free jumped 32G → **92G** post-delete. Triggered JF scan to drop indexes.
|
||||
|
||||
## 4. Mr. Robot (2015) + Kim Possible (2002)
|
||||
|
||||
### Decision matrix
|
||||
|
||||
Free on nullstone post-Inbetweeners = 92G after Mando/Obi-Wan deletes. User picked: **Mr Robot + Kim Possible** (76.6G → 15.4G headroom). Original conditional rule ("if Mr Robot fits, also import R&M S02-S03; if not, skip both") was satisfied by Mr Robot fitting comfortably, but R&M was de-scoped this round in favor of KP — see § 5.
|
||||
|
||||
### Mr. Robot
|
||||
|
||||
**Source:** `~/Downloads/Mr Robot (2015) Complete Series S01-S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)/` (44G)
|
||||
**Target:** `/home/user/media/tv/Mr. Robot (2015)/Season {01..04}/`
|
||||
|
||||
| Season | Eps | Notes |
|
||||
|---|---|---|
|
||||
| S01 | 10 | |
|
||||
| S02 | 11 | E01 is a double-episode (`S02E01-E02`) — kept as single file per Vyndros release |
|
||||
| S03 | 10 | |
|
||||
| S04 | 13 | |
|
||||
|
||||
Sidecars: 44 `.eng.srt` per ep (renamed from `.srt` to match playbook §1e canonical pattern).
|
||||
|
||||
### Kim Possible (2002)
|
||||
|
||||
**Source:** `~/Downloads/KIM POSSIBLE (2002-2019) - Complete TV Series, Season 1,2,3,4 ... 1080p AMZN Web-DL x264/` (30G)
|
||||
**Target TV:** `/home/user/media/tv/Kim Possible (2002)/Season {01..04}/`
|
||||
**Target movies:**
|
||||
- `/home/user/media/movies/Kim Possible Movie - So the Drama (2005)/`
|
||||
- `/home/user/media/movies/Kim Possible (2019)/` (live-action film)
|
||||
|
||||
| Season | Eps |
|
||||
|---|---|
|
||||
| S01 | 21 |
|
||||
| S02 | 33 |
|
||||
| S03 | 12 |
|
||||
| S04 | 23 |
|
||||
|
||||
**Skipped from source:** `d. Season 3.5 - Crossover (2005)` contains a Lilo & Stitch episode (`S02E20 - Rufus`), not a Kim Possible episode — different series, dropped. Also dropped: `Other CARTOONS with MARTIAL ARTS Themes`, `OTHER Martial Arts Movies and Shows` (Bruce Lee + Jet Li bundled in the torrent — unrelated content).
|
||||
|
||||
### rsync + scan + verify
|
||||
|
||||
Combined rsync to nullstone: 71.5 GB TV + 4.7 GB movies / `to-chk=0/187` / exit 0.
|
||||
|
||||
Perms pass: 755 dirs / 644 files / `user:user`.
|
||||
|
||||
Scan via `POST /ScheduledTasks/Running/7738148ffcd07979c7ceb148e06b3aed` → HTTP 204, Idle reached.
|
||||
|
||||
Verify (via `/Items?Recursive=true&IncludeItemTypes=Episode&fields=Path`):
|
||||
|
||||
- Mr. Robot: **44 eps** indexed, all `IndexNumber` + `ParentIndexNumber` set, names from TVDB.
|
||||
- Kim Possible: **89 eps** indexed.
|
||||
- 2 KP movies + 2 Inbetweeners movies + series records created.
|
||||
|
||||
Note: `/Shows/<sid>/Episodes?Season=N` returned 0 for both series initially — JF's per-show endpoint appears to lag behind the recursive Items index after a fresh scan; the recursive query is authoritative.
|
||||
|
||||
## 5. Rick and Morty + Mr Robot conditional (resolved)
|
||||
|
||||
Original instruction was: import Inbetweeners + R&M S02-S03 + Mr Robot if Mr Robot fits; if not, skip both R&M and Mr Robot. With the post-Mando/Obi-Wan delete, Mr Robot did fit. R&M was set aside in favor of importing Kim Possible (user's revised choice). R&M S02-S08 (4K Mesc upscales, ~107 GB) still seeding on onyx, available for the next storage upgrade. Nothing deleted.
|
||||
|
||||
## 6. Cleanup + qBt hygiene
|
||||
|
||||
Deleted sources from onyx Downloads:
|
||||
|
||||
- `The Inbetweeners 2008 S01-S03 Complete 1080p WEB-DL HEVC x265 BONE`
|
||||
- `Mr Robot (2015) Complete Series S01-S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)`
|
||||
- `KIM POSSIBLE (2002-2019) ... 1080p AMZN Web-DL x264`
|
||||
|
||||
qBt stopped (SIGTERM PID 159610), three .fastresume + .torrent pairs removed from `~/.local/share/qBittorrent/BT_backup/` (hashes `4badc62...`, `1ab3a02...`, `19223321...`), qBt restarted on port 64817 (listening verified).
|
||||
|
||||
`/home/admin/staging-jelly` cleaned.
|
||||
|
||||
## 7. Final state
|
||||
|
||||
| Resource | Before | After |
|
||||
|---|---|---|
|
||||
| nullstone `/home` free | 41G | 21G |
|
||||
| onyx Downloads | 465G | 297G (≈) |
|
||||
| JF tv items | + 0 | + Inbetweeners (18 eps), Mr. Robot (44 eps), Kim Possible (89 eps) |
|
||||
| JF movies | 13 → 17 | + Inbetweeners Movie, Inbetweeners 2, KP: So the Drama, KP (2019) |
|
||||
|
||||
## 8. Unusual things to note
|
||||
|
||||
- The Futurama RCVR repack on disk has byte-identical streams to JF's S01-S07 (706x480 HEVC + AC3 192kb/s). Confirms an earlier import of this exact release. **JF's S01-S07 is 480p DVD**, not 1080p. Worth an upgrade to a true 1080p BluRay HEVC source on the next storage refresh.
|
||||
- Mr. Robot S02E01-E02 ships as a single file (double-episode aired together). Kept as `S02E01-E02` — JF handles the dual-ep mapping natively.
|
||||
- The Kim Possible torrent bundles unrelated martial-arts content as "OTHER" dirs; not part of the series, skipped.
|
||||
- JF endpoint `/Shows/<sid>/Episodes?Season=N` lags after a fresh series import; use the recursive `/Items` endpoint with `IncludeItemTypes=Episode` for authoritative verification.
|
||||
|
||||
## Verification checklist
|
||||
|
||||
- [x] Folder + filename canonical pattern (§1b/§1f).
|
||||
- [x] Permissions `user:user`, 644/755.
|
||||
- [x] Sidecar `.eng.srt` for Mr Robot (44).
|
||||
- [x] Scan triggered + Idle.
|
||||
- [x] Per-show ep counts via recursive Items query match on-disk counts (44, 89, 18).
|
||||
- [x] Series + movie records created with correct `ProductionYear`.
|
||||
- [x] Source dirs deleted from onyx, fastresumes removed, qBt restarted clean.
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# star-wars-maul-shadow-lord-2026-2160p
|
||||
|
||||
Second run of `playbooks/import-media/` — first multi-episode TV import + first replace-with-comparison flow.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source path on onyx:** `/home/admin/Downloads/Star.Wars.Maul.Shadow.Lord.S01.2160p.DSNP.WEB-DL.DDP5.1.ENG.Atmos.ITA.SDR.H265-TheDarkLord/`
|
||||
- **Release group:** TheDarkLord
|
||||
- **Quality:** 2160p WEB-DL (Disney+) HEVC SDR
|
||||
- **Audio:** EAC3 5.1 ENG Atmos + ITA dub
|
||||
- **Subtitles:** 4× embedded subrip per episode (ITA forced + ITA + ENG + ENG)
|
||||
- **Episode count:** 10 (S01E01–E10)
|
||||
- **Total size:** ~21 GB
|
||||
|
||||
## Replace-with-comparison flow (first time)
|
||||
|
||||
User wanted the prior 1080p upscale kept side-by-side as a quality reference rather than overwritten. Approach:
|
||||
|
||||
1. Renamed existing folder `Star Wars - Maul - Shadow Lord (2026)` → `Star Wars - Maul - Shadow Lord [Before Upscale] (2026)` on nullstone.
|
||||
2. Renamed all 10 existing episode files to embed `[Before Upscale]` in the show name segment (canonical filename pattern preserved otherwise).
|
||||
3. Dropped `tvshow.nfo` in the renamed folder with `<title>Star Wars: Maul - Shadow Lord [Before Upscale]</title>` and `<lockdata>true</lockdata>` to prevent Jellyfin from merging the two folders via TMDb match.
|
||||
4. Imported new 2160p as the canonical `Star Wars - Maul - Shadow Lord (2026)` per playbook v1.0.
|
||||
|
||||
Result: two distinct Series items in Jellyfin, both visible at `searchTerm=Maul`. User can compare playback between them.
|
||||
|
||||
## Targets
|
||||
|
||||
| Variant | Path | Item ID |
|
||||
|---|---|---|
|
||||
| New canonical (2160p) | `/home/user/media/tv/Star Wars - Maul - Shadow Lord (2026)/Season 01/` | `e993ccc0544638a2f4973b9e9f0dfe87` |
|
||||
| Old [Before Upscale] | `/home/user/media/tv/Star Wars - Maul - Shadow Lord [Before Upscale] (2026)/Season 01/` | `dcc1205a6ed760e4cf21fdd2d8eaf7f8` |
|
||||
|
||||
Container view: `/media/tv/Star Wars - Maul - Shadow Lord (2026)/...` and `/media/tv/Star Wars - Maul - Shadow Lord [Before Upscale] (2026)/...`.
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| SeriesCount | 10 | 11 | +1 |
|
||||
| EpisodeCount | 197 | 207 | +10 |
|
||||
|
||||
The +1 series is the `[Before Upscale]` clone of an existing entry (folder-rename re-creates it as a fresh Series); the +10 episodes are the new 2160p canonical S01.
|
||||
|
||||
## Stream sample (E01 — Chapter 1: The Dark Revenge)
|
||||
|
||||
```
|
||||
Duration: 00:28:18.91, bitrate: 12483 kb/s
|
||||
Stream #0:0: Video: hevc (Main), yuv420p(tv), 3840x2160, 23.81 fps ← TRUE 4K UHD
|
||||
Stream #0:1(ita): Audio: eac3, 48000 Hz, 5.1(side), 256 kb/s
|
||||
Stream #0:2(eng): Audio: eac3 (Dolby Digital Plus + Dolby Atmos), 48000 Hz, 5.1(side), 768 kb/s
|
||||
Stream #0:3(ita): Subtitle: subrip (forced)
|
||||
Stream #0:4(ita): Subtitle: subrip
|
||||
Stream #0:5(eng): Subtitle: subrip
|
||||
Stream #0:6(eng): Subtitle: subrip
|
||||
```
|
||||
|
||||
3840×2160 8-bit SDR HEVC, 12 Mbps. Direct-play candidate on capable clients.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- 4× embedded text subrip per episode (ITA + ENG, plus forced ITA) — no external sidecar needed.
|
||||
- No action — embedded text subs work natively in browser via WebVTT.
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical for both old + new
|
||||
- [x] Permissions `user:user` 644 (file) / 755 (dir) — verified post-rsync chmod
|
||||
- [ ] LibraryMonitor auto-fired — DID NOT (same as Lilo run). Forced manual `POST /Library/Refresh` returned 204. Per-item `Refresh?MetadataRefreshMode=FullRefresh` also fired on both series.
|
||||
- [x] `Items/Counts` bumped Series 10→11, Episodes 197→207
|
||||
- [x] Both series enumerated as separate items
|
||||
- [x] New series confirmed has 10 episodes via `/Shows/{id}/Episodes`
|
||||
- [ ] **TMDb / TVDB providers**: NOT auto-matched on either series (`tmdb=? tvdb=?`). Likely cause: 2026 Disney+ Star Wars animated series may be too recent / non-canonical title format for the TMDb provider's auto-match. Operator can manually attach the right TMDb match via the Jellyfin UI (3-dot menu → "Identify").
|
||||
- [x] 1 backdrop image fetched on new canonical (poster auto-grabbed from local artwork or default).
|
||||
- [x] HEVC 4K stream confirmed via ffprobe; direct-play candidate.
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
- LibraryMonitor failed twice in a row to detect new media (Lilo + this run). Playbook v1.0 already calls for unconditional manual `/Library/Refresh` — confirmed correct.
|
||||
- Inter-series merging is suppressed by `tvshow.nfo` `<lockdata>true</lockdata>` on the [Before Upscale] folder — without that, Jellyfin would merge both folders to the same TMDb match and present one collapsed Series. Tested — separation holds.
|
||||
- TMDb auto-match failed for both. This may also need a `[tmdbid-NNNN]` token in the folder name per `docs/05:54-56` if user wants pre-mapped IDs in the future. For now, manual UI match is the fix.
|
||||
- Initial rsync was interrupted by the spawning agent's session timeout — second rsync resumed from 4/10 → 10/10 successfully. `rsync -a` is idempotent so the resume worked. Playbook v1.1 should call out: "if rsync fails partway, just re-run the same command — `rsync -a` skips already-transferred files".
|
||||
- 21 GB transferred in two passes; second pass was the bulk (6 episodes / ~13 GB).
|
||||
|
||||
## Operator action
|
||||
|
||||
1. Open `https://arrflix.s8n.ru` → search "Maul".
|
||||
2. Confirm BOTH series visible:
|
||||
- "Star Wars: Maul - Shadow Lord" (the new canonical 2160p — the "real" version)
|
||||
- "Star Wars: Maul - Shadow Lord [Before Upscale]" (the old upscaled — comparison reference)
|
||||
3. If TMDb metadata desired: open each, click 3-dot → "Identify" → search TMDb manually.
|
||||
4. Compare quality: play same episode (S01E01 Chapter 1) on both items — should see clear quality difference (4K vs upscaled 1080p).
|
||||
5. Once happy, source download at `/home/admin/Downloads/Star.Wars.Maul.Shadow.Lord.S01.2160p.DSNP.WEB-DL.DDP5.1.ENG.Atmos.ITA.SDR.H265-TheDarkLord/` can be deleted.
|
||||
|
||||
## Playbook updates needed (v1.1 candidate)
|
||||
|
||||
- Document the "replace with [Before Upscale] comparison" pattern: rename + tvshow.nfo lockdata + new canonical folder.
|
||||
- Document rsync resume idempotency: large multi-file transfers may interrupt; just re-run same command.
|
||||
- Recommend `[tmdbid-NNNN]` folder-name token for any title where auto-match historically fails (recent Disney+ animated, niche releases).
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
# the-guardian-snowden-2013-20130709
|
||||
|
||||
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
|
||||
(container `jellyfin-stock`), **Education** library
|
||||
(`collectionType=movies`, internet providers disabled).
|
||||
|
||||
First import into a brand-new "The Guardian" channel folder. Exposed a
|
||||
single-file folder caveat in Jellyfin movie parsing — see Notes.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Source:** YouTube — `https://www.youtube.com/watch?v=0hLjuVyIIrs`
|
||||
- **Channel:** The Guardian
|
||||
- **Tool:** `yt-dlp` on onyx
|
||||
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b` → `--merge-output-format mp4` (source native 1080p)
|
||||
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — **no user-uploaded English subs available**; `[EmbedSubtitle] There aren't any subtitles to embed`
|
||||
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg` used as Primary
|
||||
- **Staging path on onyx:** `/home/admin/staging-jelly/The Guardian/`
|
||||
|
||||
### Filename normalisation
|
||||
|
||||
Raw YouTube title:
|
||||
> `NSA whistleblower Edward Snowden: 'I don't want to live in a society that does these sort of things'`
|
||||
|
||||
yt-dlp's safe-name pass replaced the ASCII `:` with the fullwidth `:` (U+FF1A).
|
||||
Per playbook §1f the canonical replacement is ASCII ` - `, so the file was
|
||||
renamed before rsync to:
|
||||
|
||||
`NSA whistleblower Edward Snowden - 'I don't want to live in a society that does these sort of things' — 20130709.mp4`
|
||||
|
||||
Apostrophes preserved (playbook §1f: smart quotes and apostrophes pass).
|
||||
|
||||
## Target
|
||||
|
||||
- **Server:** `jellyfin-stock` on nullstone, public URL `https://tv.s8n.ru`
|
||||
- **Library:** Education (`collectionType=movies`, `EnableInternetProviders=false`)
|
||||
- **Path on host:** `/home/user/media/education/The Guardian/NSA whistleblower Edward Snowden - 'I don't want to live in a society that does these sort of things' — 20130709.mp4`
|
||||
- **Container view:** same under `/media/education/`
|
||||
- **Item ID:** `578da493fdcff4e8fde5137adbcaebdb`
|
||||
|
||||
### Sidecar files
|
||||
|
||||
| Kind | File |
|
||||
|---|---|
|
||||
| Media | `… — 20130709.mp4` (46,324,047 B, ~44 MiB) |
|
||||
| Subtitle | none |
|
||||
| Thumbnail | `… — 20130709.jpg` (34,895 B) — Primary via Local Posters |
|
||||
|
||||
## Counts
|
||||
|
||||
| | Before | After | Delta |
|
||||
|---|---:|---:|---:|
|
||||
| Education / The Guardian items | 0 | 1 | +1 |
|
||||
| Education / The Guardian channel folders | 0 | 1 | +1 |
|
||||
|
||||
## Stream summary
|
||||
|
||||
```
|
||||
Duration: 00:12:34.30, bitrate: 491 kb/s
|
||||
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 358 kb/s, 25 fps
|
||||
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
|
||||
```
|
||||
|
||||
AV1 1080p25 + stereo AAC. No subtitle streams.
|
||||
|
||||
## Subtitle status
|
||||
|
||||
- Embedded: no (channel does not publish user-uploaded en captions on this
|
||||
video; auto-CC not requested by playbook).
|
||||
- External sidecar: no.
|
||||
- Action: deferred. If subs become required, follow `playbooks/subtitles/`
|
||||
WhisperX pipeline (do **not** fall back to YouTube auto-CC; see
|
||||
`feedback_subtitle_accuracy_priority`).
|
||||
|
||||
## Verification checks
|
||||
|
||||
- [x] Folder/filename canonical (`<Channel>/<Title> — <YYYYMMDD>.mp4`).
|
||||
- [x] No forbidden chars in path (fullwidth `:` → ` - ` rename in staging).
|
||||
- [x] Permissions `user:user` 644 / 755.
|
||||
- [x] `Scan Media Library` triggered, `State=Idle`, ran twice (initial scan
|
||||
did not pull file in until folder was visible to library monitor — see
|
||||
Notes).
|
||||
- [x] `/Items?searchTerm=NSA+whistleblower&IncludeItemTypes=Movie` returns
|
||||
the expected single item after `Name` lock.
|
||||
- [x] `ImageTags.Primary` present.
|
||||
- [ ] Direct-play in mobile / Smart-TV client not exercised.
|
||||
|
||||
## Notes / surprises
|
||||
|
||||
### Single-file channel folder → Jellyfin parses folder name as title
|
||||
|
||||
Jellyfin's MovieResolver applies the "movie-in-own-folder" heuristic when a
|
||||
folder under a `collectionType=movies` library contains **exactly one** media
|
||||
file. The resulting `BaseItem.Name` is taken from the folder name, not the
|
||||
filename — so the Snowden mp4 initially indexed with `Name="The Guardian"`
|
||||
instead of the actual video title.
|
||||
|
||||
Workaround used (no playbook change required for this run):
|
||||
|
||||
```bash
|
||||
# Pull current item, set Name explicitly, LockData=true, POST back
|
||||
ID=578da493fdcff4e8fde5137adbcaebdb
|
||||
curl -sf -H "X-Emby-Token: $TOK" \
|
||||
"$SERVER_URL/Users/$USER_ID/Items/$ID" \
|
||||
| jq '.Name = "NSA whistleblower Edward Snowden - ..." | .LockData = true' \
|
||||
| curl -sf -X POST -H "X-Emby-Token: $TOK" -H 'Content-Type: application/json' \
|
||||
--data @- "$SERVER_URL/Items/$ID"
|
||||
# expect HTTP 204; Name now locked so future refresh cannot revert it.
|
||||
```
|
||||
|
||||
Generalises to any first-video import into a brand-new channel folder.
|
||||
Recommend documenting in `playbooks/import-media/README.md` §1d as a known
|
||||
caveat with the LockData fix; will propose a `playbooks/` PR after the
|
||||
second occurrence to confirm reproducibility.
|
||||
|
||||
### Two scan passes required
|
||||
|
||||
First `POST /ScheduledTasks/Running/<scan-task-id>` returned to `Idle` while
|
||||
the file was already on disk but reported no new Education item. A second
|
||||
identical invocation indexed the file. Root cause unclear — possible
|
||||
`LibraryMonitor` race during inotify pickup of the freshly created folder.
|
||||
Documenting per playbook §5d / known-broken-trigger guidance.
|
||||
144
playbooks/subtitles/CHANGELOG.md
Normal file
144
playbooks/subtitles/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Subtitle process — changelog
|
||||
|
||||
## v1 — 2026-05-09
|
||||
|
||||
Initial recipe. Drafted while running on American Dad. Distilled from doc
|
||||
03-subtitles.md (Futurama work) and the actual AD run.
|
||||
|
||||
Approach: Jellyfin RemoteSearch/Subtitles/eng → pick best non-HI/non-MT match
|
||||
via Python filter → POST download → docker cp metadata cache → media folder →
|
||||
delete cache dupes → validation refresh.
|
||||
|
||||
Scope: works on shows whose library season/episode numbering matches
|
||||
OpenSubtitles' indexed numbering. Verified passing on AD S01 (7/7 episodes).
|
||||
|
||||
### Known break — added 2026-05-09 same day
|
||||
|
||||
After S01 passed, S02 returned 0 results for every episode probed (E01, E02,
|
||||
E08, E13). Quota was fine (13 downloads remaining). Cause:
|
||||
|
||||
> Jellyfin metadata for American Dad uses **Hulu/DSP season ordering**
|
||||
> (S1=7, S2=16, S3=19, S4=16). OpenSubtitles indexes by **Fox original-airing
|
||||
> order** where S1 has 23 episodes. The plugin queries OS by
|
||||
> `(parent_imdb_id, season_number, episode_number)`. For library S02E01
|
||||
> "Bullocks to Stan" the plugin sends `S=2,E=1` but OS catalogues that
|
||||
> episode as `S=1,E=8`. Result: 0 hits.
|
||||
|
||||
Each library episode has its own correct per-episode IMDB id (e.g.
|
||||
`tt0511631` for "Bullocks to Stan") which would resolve directly via OS REST
|
||||
`imdb_id=` parameter, but the plugin doesn't expose that path.
|
||||
|
||||
## v2 — 2026-05-09
|
||||
|
||||
Approach **A** chosen: direct OpenSubtitles REST API, per-episode `imdb_id`
|
||||
lookup, bypass the Jellyfin plugin entirely. New helper at
|
||||
`lib/sub-rest-fetch.py`.
|
||||
|
||||
- API key file: `~/.config/arrflix-opensubtitles-api.txt` (mode 600)
|
||||
- Account: `Caveman5` (free tier, 20 downloads/day)
|
||||
- Saves sidecars directly to nullstone media folder via `ssh ... cat >`
|
||||
- No more docker-cp from `/config/metadata/library` cache (plugin path)
|
||||
|
||||
Recipe upgrade:
|
||||
- Step 4 swaps `lib/sub-fetch.sh` → `lib/sub-rest-fetch.py` for shows with
|
||||
non-standard season ordering.
|
||||
- Picker logic identical: filter HI/MT/AI/Forced (renamed
|
||||
`foreign_parts_only` in OS REST), prefer 23.976fps, sort by
|
||||
`download_count` desc.
|
||||
|
||||
### v2 known quirks
|
||||
|
||||
- **OpenSubtitles `/download` endpoint rejects urllib** — consistent HTTP 503
|
||||
via Python `urllib.request`, HTTP 200 via `curl` with same headers/body.
|
||||
`_curl()` shim added; all OS API calls go through it. **Each 503 still
|
||||
consumes 1 download-quota slot**, so this had to be fixed before retrying
|
||||
large batches.
|
||||
- `download_count` of `0` and `fps` of `0.0` appear on some catalogue
|
||||
entries; treat as informational, not exclusionary.
|
||||
- Some hits have `file_name` mismatching the `imdb_id` searched (OS metadata
|
||||
drift). Recipe Step 6 visual-sync check is the catch.
|
||||
|
||||
### v2 known limits
|
||||
|
||||
- Free-tier 20/day still in force (REST and plugin share the counter).
|
||||
- Recipe Step 6 (sync verification) is still manual — no automated check
|
||||
that the picked .srt actually aligns with audio.
|
||||
|
||||
## v3 — 2026-05-09
|
||||
|
||||
Approach **Addic7ed via subliminal** added as a quota-free fallback. New
|
||||
helper at `lib/sub-a7d-fetch.py`. Runs alongside v2; pick whichever fits.
|
||||
|
||||
- `subliminal` Python lib drives `addic7ed` provider, anonymous
|
||||
- OS REST is still consulted (search-only, no quota cost) to translate
|
||||
library Hulu numbering to the show's primary catalogue numbering, since
|
||||
Addic7ed and OS feature_details appear to align for at least the test
|
||||
show (American Dad)
|
||||
- Sidecar written direct to nullstone via `ssh ... cat >`
|
||||
|
||||
### v3 picker / matching
|
||||
|
||||
- subliminal returns ordered candidates by match score; takes first
|
||||
- "!" in series name breaks subliminal's matcher; recipe strips it before
|
||||
building the synthetic filename for `Video.fromname()`
|
||||
- Synthetic filename pattern: `Series.Name.Year.SXXEYY.HDTV.x264.mkv`
|
||||
|
||||
### v3 known quirks
|
||||
|
||||
- Some episodes return 0 hits at addic7ed for the OS-feat-details S/E we
|
||||
pass — likely cases where addic7ed indexes by Fox airing order while OS
|
||||
uses DVD-compressed (or vice versa). On American Dad, ~9 of 58 episodes
|
||||
missed via this path. Fall back to v2 OS REST when quota allows.
|
||||
- One episode (`Black Mystery Month`) had a hit but downloaded empty
|
||||
content — addic7ed-side cataloguing error or temp 0-byte upload.
|
||||
- Per-show coverage varies: Addic7ed has near-complete English on broadcast
|
||||
US shows but spotty for animated specials and obscure titles.
|
||||
|
||||
### v3 known limits
|
||||
|
||||
- English coverage best; non-English near-empty
|
||||
- Anonymous downloads work but heavy bursts may trigger Addic7ed's
|
||||
bot detection and short IP throttle (~1 hour). The script makes no
|
||||
effort at jittering / backoff
|
||||
- No automated sync-quality check; recipe Step 6 still manual
|
||||
|
||||
## v3.5 — 2026-05-10 (stop-gap path for niche YouTube-distributed shows)
|
||||
|
||||
For shows that distribute on YouTube and have no community subs anywhere
|
||||
(verified by parallel research agents covering OS REST / OS legacy /
|
||||
Addic7ed / SubDL / SubSource / Podnapisi for 5 niche shows), pull the
|
||||
YouTube auto-CC track via yt-dlp and clean it.
|
||||
|
||||
- New helper: `lib/sub-yt-fetch.sh` (yt-dlp wrapper) + `lib/yt-clean.py`
|
||||
(rolling-window VTT → flat SRT cleaner)
|
||||
- First applied to **Sassy the Sasquatch (2022)**, S01 5/5 episodes
|
||||
- Reusable for the rest of the Big Lez universe (same channel hosts
|
||||
Donny & Clarence, Mike Nolan, Big Lez Saga)
|
||||
|
||||
### v3.5 known limits — explicitly violates STYLE.md "best quality"
|
||||
|
||||
- Lowercase, no punctuation, no sentence segmentation
|
||||
- Proper-noun mishears (Sassy → "sasha", Big Lez → "Big Less")
|
||||
- Profanity censored as `[ __ ]` by YouTube's ASR
|
||||
- Will be replaced wholesale by v4 WhisperX (see ROADMAP H5)
|
||||
|
||||
### v3.5 also discovered
|
||||
|
||||
- **OpenSubtitles VIP would not have helped.** Verified: VIP is download-cap
|
||||
relief and ad removal, not coverage unlock. Same catalog as free.
|
||||
- **Mike Nolan special-case**: a YouTube upload titled
|
||||
"MIKE NOLAN SHOW | COMPLETE SEASON | SUBTITLES" (Oct 2025) carries
|
||||
hand-typed CCs. When subbing Mike Nolan, prefer ripping that single
|
||||
upload over the per-episode auto-CC playlist path.
|
||||
|
||||
## v4 — planned (see ROADMAP H5)
|
||||
|
||||
Path: **WhisperX large-v3 on friend RTX 4080 node** (`100.64.0.3`).
|
||||
|
||||
- Replaces v3.5 stop-gap with full-quality auto-transcription
|
||||
- Per-show proper-noun prompt at `playbooks/subtitles/prompts/<show>.yaml`
|
||||
- New helper: `lib/sub-whisperx-fetch.py` (TBD)
|
||||
- Expected WER: 4–6% on noisy / animated dialogue (vs ~12% YT auto-CC)
|
||||
- Restores STYLE.md "one clean English sub per ep" bar for niche shows
|
||||
- Cloud fallback: ElevenLabs Scribe v2 (~$0.40/hr, ~2.2% WER) for any
|
||||
episode WhisperX still misses
|
||||
82
playbooks/subtitles/COVERAGE.md
Normal file
82
playbooks/subtitles/COVERAGE.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# ARRFLIX subtitle coverage
|
||||
|
||||
_Generated 2026-05-10 05:09 UTC by `playbooks/subtitles/lib/audit-coverage.py`._
|
||||
_Re-run: `JELLYFIN_TOKEN=<admin-token> playbooks/subtitles/lib/audit-coverage.py`._
|
||||
|
||||
Legend: `█` eng sidecar · `▒` eng embedded only · `▓` other-lang embedded · `·` none
|
||||
|
||||
## TV shows
|
||||
|
||||
```
|
||||
Show Eps sc emb none Status
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
American Dad! 58 49 0 9 PARTIAL (84%)
|
||||
██████████████████████████·████████·█████······███
|
||||
██·█████
|
||||
|
||||
Archer 23 0 23 0 OK-EMBED (no sidecars)
|
||||
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
|
||||
|
||||
Futurama 72 0 72 0 OK-EMBED (no sidecars)
|
||||
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
|
||||
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
|
||||
|
||||
Obi-Wan Kenobi 6 0 6 0 OK-EMBED (no sidecars)
|
||||
▒▒▒▒▒▒
|
||||
|
||||
Rick and Morty 11 0 11 0 OK-EMBED (no sidecars)
|
||||
▒▒▒▒▒▒▒▒▒▒▒
|
||||
|
||||
Sassy the Sasquatch 5 5 0 0 OK (100%)
|
||||
█████
|
||||
|
||||
Star Wars: Maul - Shadow Lord 10 0 10 0 OK-EMBED (no sidecars)
|
||||
▒▒▒▒▒▒▒▒▒▒
|
||||
|
||||
Star Wars: Maul - Shadow Lord [Before Upscale] 10 0 10 0 OK-EMBED (no sidecars)
|
||||
▒▒▒▒▒▒▒▒▒▒
|
||||
|
||||
The Big Lez Saga (2022) 3 3 0 0 OK (100%)
|
||||
███
|
||||
|
||||
The Donny & Clarence Show (2024) 5 5 0 0 OK (100%)
|
||||
█████
|
||||
|
||||
The Mandalorian 24 0 24 0 OK-EMBED (no sidecars)
|
||||
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
|
||||
|
||||
The Mike Nolan Show 3 3 0 0 OK (100%)
|
||||
███
|
||||
|
||||
```
|
||||
|
||||
## Movies
|
||||
|
||||
```
|
||||
Title sc emb Status
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
Idiocracy 0 2 OK (embedded)
|
||||
Lilo & Stitch 1 2 OK (sidecar)
|
||||
The Dark Knight 0 1 OK (embedded)
|
||||
The Incredible Hulk 0 1 OK (embedded)
|
||||
```
|
||||
|
||||
## Aggregate
|
||||
|
||||
| Metric | Count | % |
|
||||
|---|---:|---:|
|
||||
| Episodes total | 230 | — |
|
||||
| eng sidecar | 65 | 28% |
|
||||
| eng embedded only | 156 | 67% |
|
||||
| other-lang embedded only | 0 | 0% |
|
||||
| no subs anywhere | 9 | 3% |
|
||||
| Movies total | 4 | — |
|
||||
| Movies with any eng sub | 4 | 100% |
|
||||
|
||||
## Status meanings
|
||||
|
||||
- **OK** — every episode has an external `.eng.srt` sidecar (STYLE.md happy path)
|
||||
- **OK-EMBED** — all eps playable in English but no sidecars; `SaveSubtitlesWithMedia` won't trigger fetch since Jellyfin sees an eng track already
|
||||
- **PARTIAL (X %)** — some sidecars, some gaps
|
||||
- **NEEDS SUBS** — zero subs of any language; v3 / v3.5 / v4 fetch required
|
||||
- **OTHER-LANG ONLY** (movies) — embedded subs exist but none in English
|
||||
14
playbooks/subtitles/README.md
Normal file
14
playbooks/subtitles/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Subtitles playbook — moved
|
||||
|
||||
The procedure, STYLE.md, COVERAGE.md, STOPGAP-SUBS.md and the helper
|
||||
scripts (`audit-coverage.py`, `sub-fetch.sh`, `sub-rest-fetch.py`,
|
||||
`sub-a7d-fetch.py`, `sub-yt-fetch.sh`, `yt-clean.py`) have moved to
|
||||
beta-flix and been rewritten / generalised for stock Jellyfin 10.11.8:
|
||||
|
||||
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/subtitles/>
|
||||
|
||||
Per-show fetch logs stay here under [`runs/`](runs/) — they're history,
|
||||
not procedure. [`CHANGELOG.md`](CHANGELOG.md) preserves the ARRFLIX recipe
|
||||
evolution (v1 → v3.5) for context. The local [`lib/`](lib/) copies remain
|
||||
as the ARRFLIX-host-specific reference (hardcoded Jellyfin container
|
||||
name, internal URL, and SSH target).
|
||||
56
playbooks/subtitles/STOPGAP-SUBS.md
Normal file
56
playbooks/subtitles/STOPGAP-SUBS.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Stop-gap subs — pending Whisper cross-ref
|
||||
|
||||
Shows whose current subtitles ship from a path that explicitly violates
|
||||
[`STYLE.md`](STYLE.md). Quality is "acceptable, not great" (~85 %). When
|
||||
v4 WhisperX (ROADMAP H5) lands on the friend RTX 4080 node, **regenerate
|
||||
every show on this list** with proper-noun-prompted transcription and
|
||||
replace the sidecars in place. Keep this file as the v4 worklist.
|
||||
|
||||
**NOT a stop-gap** (do NOT log here): embedded original-release bitmap
|
||||
subs (PGS, VobSub, `dvd_subtitle`). Per [`STYLE.md`](STYLE.md) tier 2,
|
||||
those are first-class — they're the original studio render and ship
|
||||
as-is. Examples currently in library that are correct, not stop-gap:
|
||||
- Lilo & Stitch (2002) — 2× embedded English PGS
|
||||
- Archer (2009) S02 — 3× embedded DVD-bitmap (eng/spa/fre)
|
||||
|
||||
Optional `pgsrip` OCR sidecar for those is a UX nicety, not a
|
||||
correctness fix — see STYLE.md "OCR bitmap → text".
|
||||
|
||||
## Active stop-gaps
|
||||
|
||||
| Show | Eps subbed | Source path | Why stop-gap | Owner verdict | Logged |
|
||||
|---|---|---|---|---|---|
|
||||
| Sassy the Sasquatch (2022) | S01 5/5 | v3.5 YouTube auto-CC | lowercase, no punctuation, names mangled (`Sassy → sasha`), profanity = `[ __ ]` | "85 % the way there, acceptable, fine" — keep until v4 | 2026-05-10 |
|
||||
|
||||
## When more Big Lez universe shows ship via v3.5
|
||||
|
||||
Same channel hosts these — when subbed via the v3.5 yt-dlp path, append
|
||||
to the table above:
|
||||
|
||||
- The Donny & Clarence Show (2024)
|
||||
- The Big Lez Saga (2022)
|
||||
- The Mike Nolan Show (2016) — but **try the YT "COMPLETE SEASON | SUBTITLES"
|
||||
upload first** for hand-typed CCs before falling back to auto-CC
|
||||
|
||||
## v4 WhisperX rebuild plan
|
||||
|
||||
When the friend node (`100.64.0.3`, per memory `project_friend_gpu.md`) is
|
||||
back online:
|
||||
|
||||
1. Install WhisperX on the node (CUDA 12 + cuDNN 9 + faster-whisper +
|
||||
pyannote VAD).
|
||||
2. For each show in the table above, write
|
||||
`playbooks/subtitles/prompts/<show>.yaml` with the recurring proper
|
||||
nouns the YT auto-CC mangled.
|
||||
3. Run `lib/sub-whisperx-fetch.py` (TBD, ROADMAP H5) per show. Each
|
||||
episode: pull mkv → ffmpeg extract 16k mono wav → WhisperX large-v3
|
||||
with `--initial_prompt` from the yaml → SRT → SSH push to nullstone
|
||||
with library filename, **overwriting the v3.5 sidecar in place**.
|
||||
4. Tick off the row from the table; move it to a "Cleared via v4" archive
|
||||
section below this one (kept as record).
|
||||
5. Library scan; verify Jellyfin still reports 1 external eng sub stream
|
||||
per ep (no dupes from v3.5 + v4 stacking).
|
||||
|
||||
## Cleared via v4 (archive)
|
||||
|
||||
(empty — populate as v4 rebuilds land)
|
||||
134
playbooks/subtitles/STYLE.md
Normal file
134
playbooks/subtitles/STYLE.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Subtitle USER-G style — ARRFLIX
|
||||
|
||||
The bar every fetch should hit. If a recipe step would violate any of these,
|
||||
stop and ask before proceeding.
|
||||
|
||||
## Source priority (highest → lowest)
|
||||
|
||||
Accuracy beats format. Use this tier ladder before reaching for OCR/AI:
|
||||
|
||||
1. **Original release text subs** (`.srt`/`.ass` from disc/streamer rip,
|
||||
embedded or sidecar). Ground truth — ship as-is.
|
||||
2. **Original release bitmap subs** (PGS, VobSub, `dvd_subtitle`,
|
||||
embedded). **Acceptable in their native form** — they ARE the original
|
||||
words from the source master, just rendered as images. Jellyfin server
|
||||
burns them in for clients that can't render natively. Optionally
|
||||
OCR-extract a `.srt` sidecar alongside (do NOT replace the embedded
|
||||
stream) when client-side styling, search, or mobile rendering matters.
|
||||
3. **Trusted text rips** from OpenSubtitles (verified uploads, hash-match
|
||||
or high-download-count + frame-rate-match).
|
||||
4. **WhisperX rebuild** with `--initial_prompt` proper-nouns yaml — only
|
||||
when no original exists (e.g. user-uploaded YT content with auto-CC).
|
||||
Logged in [`STOPGAP-SUBS.md`](STOPGAP-SUBS.md) until cleared.
|
||||
|
||||
Tier 1 and 2 are first-class. Tier 3 is a fallback. Tier 4 is a stop-gap.
|
||||
|
||||
## What lands on disk
|
||||
|
||||
- **At least one** English subtitle stream per episode (embedded OR
|
||||
sidecar — not both required).
|
||||
- Sidecar filename when used: `<videobasename>.eng.srt` — no
|
||||
language-region tags (`en-US`), no flag stack on regular subs (no
|
||||
`.sdh`, no `.forced`, no `.cc` unless there genuinely is no
|
||||
plain-English option).
|
||||
- Sidecar format: `.srt` (SubRip text). For embedded: keep the original
|
||||
codec (`subrip`, `ass`, `pgs`, `vobsub`, `dvd_subtitle`) — do NOT
|
||||
re-mux just to convert format. Convert only when extracting to disk:
|
||||
text codecs via `ffmpeg -map 0:s:0 -c:s srt`, bitmap codecs via OCR
|
||||
(see "OCR bitmap → text" below).
|
||||
- Encoding (sidecar): UTF-8. Re-encode with `iconv` if a sidecar comes
|
||||
back as cp1252 / windows-1250.
|
||||
|
||||
## What gets picked
|
||||
|
||||
In order:
|
||||
|
||||
1. **English** language — `eng` / `en`. Never auto-pick `en-US`/`en-GB`
|
||||
variants over plain `en`; treat them equivalent for matching.
|
||||
2. **No SDH / Hearing Impaired** — drop any sub flagged `hearing_impaired`,
|
||||
`sdh`, `cc`. Only fall back to SDH if no plain-English option exists.
|
||||
3. **No machine / AI translation** — drop `machine_translated`,
|
||||
`ai_translated`. Hand-authored subs only.
|
||||
4. **No forced subtitles** — drop `foreign_parts_only` / `Forced` unless the
|
||||
episode has English audio with foreign-language scenes that need
|
||||
translation (rare for US shows).
|
||||
5. **Frame-rate match** — prefer entries whose declared fps matches the
|
||||
source video (typically 23.976 for our masters). Treat `0.0` as unknown
|
||||
and fall through to step 6.
|
||||
6. **Highest download count** within the surviving candidates — proxies for
|
||||
"the version everyone agreed was best."
|
||||
|
||||
After fetch, **eyeball-verify one sample episode per show** plays in sync
|
||||
(±1 s on a known dialogue line) before declaring the show done.
|
||||
|
||||
## What doesn't ship
|
||||
|
||||
- Multiple language tracks per episode (no German/French alternatives —
|
||||
English-only library). Drop non-English embedded streams via
|
||||
`mkvpropedit` only if user complains about client picker clutter; do
|
||||
NOT silently strip them on import.
|
||||
- Director's commentary, behind-the-scenes, song-only subs.
|
||||
- Subs that cover only a partial runtime (the partial-cover heuristic isn't
|
||||
scripted yet; spot-check duration vs episode runtime if a srt looks short).
|
||||
- "All-episodes-in-one" mega-packs treated as a single episode's sidecar.
|
||||
|
||||
## OCR bitmap → text (optional, tier-2 augmentation)
|
||||
|
||||
Embedded PGS/VobSub/`dvd_subtitle` are acceptable as-is (tier 2). OCR
|
||||
becomes worthwhile when: (a) client repeatedly transcodes due to bitmap
|
||||
burn-in (CPU pressure on nullstone — no GPU transcode available), (b)
|
||||
user wants to restyle font/size on a specific show, (c) mobile client
|
||||
renders bitmap subs poorly.
|
||||
|
||||
Recipe (`pgsrip`, batch-friendly, Tesseract-backed):
|
||||
|
||||
```bash
|
||||
pip install pgsrip
|
||||
# PGS: extract embedded to .sup
|
||||
ffmpeg -i input.mkv -map 0:s:0 -c copy subs.sup
|
||||
pgsrip --language eng subs.sup # -> subs.srt
|
||||
|
||||
# VobSub/dvd_subtitle: extract to .idx + .sub
|
||||
mkvextract input.mkv tracks 2:subs.idx
|
||||
pgsrip subs.idx # -> subs.srt
|
||||
```
|
||||
|
||||
OCR accuracy ~90–95 % raw, ~95–98 % after Subtitle Edit cleanup. Source
|
||||
words are correct (it's transcription of original render, not Whisper
|
||||
hallucination) — only font recognition fights you. Resulting `.srt`
|
||||
ships as sidecar **alongside** the embedded bitmap stream, not as a
|
||||
replacement.
|
||||
|
||||
## How the UI presents subs
|
||||
|
||||
The detail-page subtitle dropdown is shimmed via
|
||||
`web-overrides/index.html` (markers `SUB-LABEL-SHIM-BEGIN/END`). Stock
|
||||
Jellyfin shows e.g. `English - SUBRIP - External - Default`; the shim
|
||||
collapses to `English`, with `(Forced)` / `(SDH)` / `(Hearing Impaired)`
|
||||
suffixes only when those flags actually apply. `Default` is dropped — it's
|
||||
redundant when there's only one stream per language.
|
||||
|
||||
Revert: `bin/revert-sub-label-shim.sh`.
|
||||
|
||||
## Why these rules
|
||||
|
||||
- Boutique-release-group quality bar from
|
||||
[`README.md`](../../README.md): "every show and film is the best version
|
||||
I could put together."
|
||||
- **Original-release subs > pretty format.** The DVD/BD/streamer master
|
||||
is the canonical script — bitmap or text, those are the words the
|
||||
studio shipped. An OCR'd or AI-rebuilt sidecar is a derivative that
|
||||
introduces error (font confusion, mistranscription); the original
|
||||
doesn't. Especially true for older shows (Futurama S1–S3, Archer
|
||||
early seasons) where the master is the only authoritative source and
|
||||
upscale artifacts already dominate the visual experience — bitmap
|
||||
subs match the source vibe.
|
||||
- One-language library = one stream per ep = no need to expose codec or
|
||||
source in UI.
|
||||
- SDH/CC adds `[door slams]`, `[music]` etc. — distracting on first watch
|
||||
and not what someone reaches for unless they specifically need it.
|
||||
- Machine / AI translations are inconsistent and often wrong on slang or
|
||||
show-specific terms (esp. animated comedies).
|
||||
- Frame-rate-matched subs sync without manual offset on first try; mismatch
|
||||
generally still works on NTSC (29.97 vs 23.976 are the same elapsed time)
|
||||
but hash-match or fps-match removes that gamble.
|
||||
240
playbooks/subtitles/lib/audit-coverage.py
Executable file
240
playbooks/subtitles/lib/audit-coverage.py
Executable file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env python3
|
||||
"""ARRFLIX subtitle coverage audit — read-only.
|
||||
|
||||
Queries Jellyfin live (via SSH+curl into the nullstone container), classifies
|
||||
every TV episode and movie by the source of its English subtitle (sidecar /
|
||||
embedded / none), and renders a Markdown report. Designed to be regenerated
|
||||
on demand and committed alongside the recipe so the repo always has a
|
||||
current view of what's subbed and what isn't.
|
||||
|
||||
Usage:
|
||||
JELLYFIN_TOKEN=<admin-token> \\
|
||||
playbooks/subtitles/lib/audit-coverage.py [--out PATH]
|
||||
|
||||
Default output path: playbooks/subtitles/COVERAGE.md (relative to repo root).
|
||||
With --stdout, prints to stdout instead of writing the file.
|
||||
|
||||
Env (required):
|
||||
JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin
|
||||
|
||||
Env (optional):
|
||||
NULLSTONE SSH target, default user@192.168.0.100
|
||||
|
||||
Classification (per episode):
|
||||
█ eng sidecar STYLE.md happy path
|
||||
▒ eng embedded only playable but doesn't satisfy "1 .eng.srt per ep"
|
||||
▓ other-lang embedded no English at all, only foreign subs muxed
|
||||
· none nothing — fetch needed
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100")
|
||||
JF_BASE = "http://localhost:8096"
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
DEFAULT_OUT = os.path.join(REPO_ROOT, "playbooks", "subtitles", "COVERAGE.md")
|
||||
|
||||
|
||||
def die(msg: str, code: int = 1) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def jellyfin(path: str, params: dict | None = None) -> dict:
|
||||
tok = os.environ.get("JELLYFIN_TOKEN") or die("JELLYFIN_TOKEN not set")
|
||||
qs = "?" + urllib.parse.urlencode(params, safe=",") if params else ""
|
||||
url = JF_BASE + path + qs
|
||||
cmd = ["ssh", NULLSTONE,
|
||||
f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"]
|
||||
return json.loads(subprocess.check_output(cmd, text=True))
|
||||
|
||||
|
||||
def stream_summary(item: dict) -> dict:
|
||||
out = {"eng_sidecar": 0, "eng_embed": 0, "other_sidecar": 0,
|
||||
"other_embed": 0, "embedded_any": 0, "sub_total": 0}
|
||||
for st in item.get("MediaStreams", []) or []:
|
||||
if st.get("Type") != "Subtitle":
|
||||
continue
|
||||
out["sub_total"] += 1
|
||||
lang = (st.get("Language") or "").lower()
|
||||
if st.get("IsExternal"):
|
||||
if lang in ("eng", "en"):
|
||||
out["eng_sidecar"] += 1
|
||||
else:
|
||||
out["other_sidecar"] += 1
|
||||
else:
|
||||
out["embedded_any"] += 1
|
||||
if lang in ("eng", "en"):
|
||||
out["eng_embed"] += 1
|
||||
else:
|
||||
out["other_embed"] += 1
|
||||
return out
|
||||
|
||||
|
||||
def ep_status_char(s: dict) -> str:
|
||||
if s["eng_sidecar"]: return "█"
|
||||
if s["eng_embed"]: return "▒"
|
||||
if s["embedded_any"]: return "▓"
|
||||
if s["sub_total"] == 0: return "·"
|
||||
return "?"
|
||||
|
||||
|
||||
def render_show_block(name: str, eps: list[dict]) -> tuple[str, dict]:
|
||||
eps.sort(key=lambda e: (e.get("ParentIndexNumber", 0), e.get("IndexNumber", 0)))
|
||||
counts = {"eng_sc": 0, "eng_emb": 0, "embed_other": 0, "none": 0}
|
||||
bar = []
|
||||
for e in eps:
|
||||
sm = stream_summary(e)
|
||||
if sm["eng_sidecar"]: counts["eng_sc"] += 1
|
||||
elif sm["eng_embed"]: counts["eng_emb"] += 1
|
||||
elif sm["embedded_any"]: counts["embed_other"] += 1
|
||||
else: counts["none"] += 1
|
||||
bar.append(ep_status_char(sm))
|
||||
|
||||
n = len(eps)
|
||||
pct = counts["eng_sc"] * 100 // n if n else 0
|
||||
if counts["eng_sc"] == n:
|
||||
status = f"OK ({pct}%)"
|
||||
elif counts["eng_sc"] + counts["eng_emb"] == n:
|
||||
status = "OK-EMBED (no sidecars)"
|
||||
elif counts["none"] == n:
|
||||
status = "NEEDS SUBS"
|
||||
else:
|
||||
status = f"PARTIAL ({pct}%)"
|
||||
|
||||
line = (f"{name:<42} {n:>4} {counts['eng_sc']:>6} "
|
||||
f"{counts['eng_emb']:>7} {counts['none']:>4} {status}")
|
||||
bar_lines = []
|
||||
for i in range(0, len(bar), 50):
|
||||
bar_lines.append(" " + "".join(bar[i:i+50]))
|
||||
return line + "\n" + "\n".join(bar_lines), counts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default=DEFAULT_OUT)
|
||||
ap.add_argument("--stdout", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
print("[audit] querying Jellyfin…", file=sys.stderr)
|
||||
series = jellyfin("/Items", {
|
||||
"IncludeItemTypes": "Series",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path",
|
||||
"SortBy": "SortName",
|
||||
})["Items"]
|
||||
eps = jellyfin("/Items", {
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,MediaStreams,SeriesName,ParentIndexNumber,IndexNumber",
|
||||
})["Items"]
|
||||
movies = jellyfin("/Items", {
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,MediaStreams",
|
||||
"SortBy": "SortName",
|
||||
})["Items"]
|
||||
|
||||
by_series = collections.defaultdict(list)
|
||||
for e in eps:
|
||||
by_series[e.get("SeriesId") or e.get("SeasonId", "???")].append(e)
|
||||
|
||||
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
out = []
|
||||
out.append("# ARRFLIX subtitle coverage")
|
||||
out.append("")
|
||||
out.append(f"_Generated {now} by `playbooks/subtitles/lib/audit-coverage.py`._")
|
||||
out.append(f"_Re-run: `JELLYFIN_TOKEN=<admin-token> playbooks/subtitles/lib/audit-coverage.py`._")
|
||||
out.append("")
|
||||
out.append("Legend: `█` eng sidecar · `▒` eng embedded only · "
|
||||
"`▓` other-lang embedded · `·` none")
|
||||
out.append("")
|
||||
out.append("## TV shows")
|
||||
out.append("")
|
||||
out.append("```")
|
||||
out.append(f"{'Show':<42} {'Eps':>4} {'sc':>6} {'emb':>7} {'none':>4} Status")
|
||||
out.append("─" * 78)
|
||||
|
||||
agg = {"eng_sc": 0, "eng_emb": 0, "embed_other": 0, "none": 0, "total": 0}
|
||||
for s in sorted(series, key=lambda x: x["Name"].lower()):
|
||||
sid = s["Id"]
|
||||
block, counts = render_show_block(s["Name"], by_series.get(sid, []))
|
||||
out.append(block)
|
||||
out.append("")
|
||||
for k in agg:
|
||||
if k == "total": continue
|
||||
agg[k] += counts[k]
|
||||
agg["total"] += sum(counts.values())
|
||||
out.append("```")
|
||||
out.append("")
|
||||
|
||||
out.append("## Movies")
|
||||
out.append("")
|
||||
out.append("```")
|
||||
out.append(f"{'Title':<58} {'sc':>6} {'emb':>7} Status")
|
||||
out.append("─" * 78)
|
||||
m_eng = 0
|
||||
for m in sorted(movies, key=lambda x: x["Name"].lower()):
|
||||
sm = stream_summary(m)
|
||||
if sm["eng_sidecar"]:
|
||||
status = "OK (sidecar)"
|
||||
elif sm["eng_embed"]:
|
||||
status = "OK (embedded)"
|
||||
elif sm["embedded_any"]:
|
||||
status = "OTHER-LANG ONLY"
|
||||
elif sm["sub_total"] == 0:
|
||||
status = "NEEDS SUBS"
|
||||
else:
|
||||
status = "?"
|
||||
if sm["eng_sidecar"] or sm["eng_embed"]:
|
||||
m_eng += 1
|
||||
name = m["Name"]
|
||||
if len(name) > 56:
|
||||
name = name[:55] + "…"
|
||||
out.append(f"{name:<58} {sm['eng_sidecar']:>6} {sm['eng_embed']:>7} {status}")
|
||||
out.append("```")
|
||||
out.append("")
|
||||
|
||||
out.append("## Aggregate")
|
||||
out.append("")
|
||||
n = agg["total"] or 1
|
||||
out.append("| Metric | Count | % |")
|
||||
out.append("|---|---:|---:|")
|
||||
out.append(f"| Episodes total | {agg['total']} | — |")
|
||||
out.append(f"| eng sidecar | {agg['eng_sc']} | {agg['eng_sc']*100//n}% |")
|
||||
out.append(f"| eng embedded only | {agg['eng_emb']} | {agg['eng_emb']*100//n}% |")
|
||||
out.append(f"| other-lang embedded only | {agg['embed_other']} | {agg['embed_other']*100//n}% |")
|
||||
out.append(f"| no subs anywhere | {agg['none']} | {agg['none']*100//n}% |")
|
||||
out.append(f"| Movies total | {len(movies)} | — |")
|
||||
out.append(f"| Movies with any eng sub | {m_eng} | "
|
||||
f"{m_eng*100//max(len(movies),1)}% |")
|
||||
out.append("")
|
||||
out.append("## Status meanings")
|
||||
out.append("")
|
||||
out.append("- **OK** — every episode has an external `.eng.srt` sidecar (STYLE.md happy path)")
|
||||
out.append("- **OK-EMBED** — all eps playable in English but no sidecars; `SaveSubtitlesWithMedia` won't trigger fetch since Jellyfin sees an eng track already")
|
||||
out.append("- **PARTIAL (X %)** — some sidecars, some gaps")
|
||||
out.append("- **NEEDS SUBS** — zero subs of any language; v3 / v3.5 / v4 fetch required")
|
||||
out.append("- **OTHER-LANG ONLY** (movies) — embedded subs exist but none in English")
|
||||
|
||||
rendered = "\n".join(out) + "\n"
|
||||
if args.stdout:
|
||||
sys.stdout.write(rendered)
|
||||
else:
|
||||
with open(args.out, "w") as f:
|
||||
f.write(rendered)
|
||||
print(f"[audit] wrote {args.out}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
260
playbooks/subtitles/lib/sub-a7d-fetch.py
Executable file
260
playbooks/subtitles/lib/sub-a7d-fetch.py
Executable file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Subtitle fetcher v3 — Addic7ed via subliminal.
|
||||
|
||||
Free, no daily quota. Uses OpenSubtitles REST (search-only, no downloads,
|
||||
no quota burn) to translate library S/E numbering to the show's primary
|
||||
catalogue numbering (e.g. Hulu→Fox for American Dad), then drives
|
||||
subliminal's addic7ed provider for the actual download.
|
||||
|
||||
Why v3: OS REST `/download` is capped at 20/day on free tier. Addic7ed
|
||||
serves anonymous downloads with no daily limit. v2 (lib/sub-rest-fetch.py)
|
||||
remains the right tool when quota isn't the bottleneck — addic7ed has
|
||||
narrower coverage than OpenSubtitles (English only, mostly).
|
||||
|
||||
Picker: subliminal's own scoring against the matched Video (filename, S/E,
|
||||
year). For AD, addic7ed catalogues by Fox airing order, so the script
|
||||
remaps library Hulu numbering via per-ep IMDB id lookup on OS REST.
|
||||
|
||||
Usage:
|
||||
sub-a7d-fetch.py <series-id> --season N [--start E] [--end E]
|
||||
sub-a7d-fetch.py <series-id> --all
|
||||
|
||||
Env (required):
|
||||
JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin
|
||||
OPENSUBTITLES_API_KEY Path to file holding the OS REST key (search only)
|
||||
|
||||
Env (optional):
|
||||
NULLSTONE SSH target, default user@192.168.0.100
|
||||
DRY_RUN=1 search + remap only, no download
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
from babelfish import Language
|
||||
from subliminal import (Video, region, list_subtitles, download_subtitles,
|
||||
save_subtitles)
|
||||
|
||||
OS_BASE = "https://api.opensubtitles.com/api/v1"
|
||||
USER_AGENT = "arrflix v1.0.0"
|
||||
JF_BASE = "http://localhost:8096"
|
||||
NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100")
|
||||
|
||||
region.configure("dogpile.cache.memory")
|
||||
|
||||
|
||||
def die(msg: str, code: int = 1) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def env_or_die(name: str) -> str:
|
||||
v = os.environ.get(name)
|
||||
if not v:
|
||||
die(f"{name} not set")
|
||||
return v
|
||||
|
||||
|
||||
def load_api_key() -> str:
|
||||
path = env_or_die("OPENSUBTITLES_API_KEY")
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
def jellyfin(path: str, params: dict | None = None) -> dict:
|
||||
tok = env_or_die("JELLYFIN_TOKEN")
|
||||
qs = "?" + urllib.parse.urlencode(params, safe=",") if params else ""
|
||||
url = JF_BASE + path + qs
|
||||
cmd = ["ssh", NULLSTONE,
|
||||
f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"]
|
||||
return json.loads(subprocess.check_output(cmd, text=True))
|
||||
|
||||
|
||||
def list_episodes(series_id: str) -> list[dict]:
|
||||
d = jellyfin("/Items", {
|
||||
"ParentId": series_id,
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,ParentIndexNumber,IndexNumber,ProviderIds",
|
||||
"SortBy": "ParentIndexNumber,IndexNumber",
|
||||
})
|
||||
return d["Items"]
|
||||
|
||||
|
||||
def imdb_strip(s: str | None) -> str | None:
|
||||
if not s:
|
||||
return None
|
||||
return s[2:] if s.startswith("tt") else s
|
||||
|
||||
|
||||
def os_search_imdb(api_key: str, imdb_no_tt: str) -> tuple[int, int] | None:
|
||||
"""Look up the show's primary catalogue (season, episode) by per-ep IMDB id.
|
||||
Uses OS feature_details S/E (which appears to align with what Addic7ed
|
||||
indexes for at least the test shows). Search calls do not consume the
|
||||
daily quota. If the resulting download mismatches expected dialogue,
|
||||
consider re-running with the v2 OS REST path which uses imdb_id directly."""
|
||||
cmd = ["curl", "-sSf",
|
||||
"-H", f"Api-Key: {api_key}",
|
||||
"-H", f"User-Agent: {USER_AGENT}",
|
||||
f"{OS_BASE}/subtitles?imdb_id={imdb_no_tt}&languages=en&per_page=5"]
|
||||
raw = subprocess.check_output(cmd)
|
||||
j = json.loads(raw.decode())
|
||||
for h in j.get("data", []):
|
||||
fd = h.get("attributes", {}).get("feature_details", {})
|
||||
s, e = fd.get("season_number"), fd.get("episode_number")
|
||||
if s and e:
|
||||
return int(s), int(e)
|
||||
return None
|
||||
|
||||
|
||||
def episode_to_paths(ep: dict) -> tuple[str, str]:
|
||||
"""Return (remote_dir, base_filename) for sidecar placement on nullstone."""
|
||||
container_path = ep["Path"]
|
||||
host_path = container_path.replace("/media/", "/home/user/media/")
|
||||
return os.path.dirname(host_path), os.path.splitext(os.path.basename(host_path))[0]
|
||||
|
||||
|
||||
def addic7ed_safe_name(series: str, year: int | None, fox_s: int, fox_e: int) -> str:
|
||||
"""Build filename that subliminal+addic7ed match. Strip '!' (breaks matcher)
|
||||
and other punctuation; keep year if known."""
|
||||
cleaned = re.sub(r"[!?:]", "", series).replace(" ", ".")
|
||||
yearbit = f".{year}" if year else ""
|
||||
return f"{cleaned}{yearbit}.S{fox_s:02d}E{fox_e:02d}.HDTV.x264.mkv"
|
||||
|
||||
|
||||
def write_sidecar_remote(content: bytes, remote_path: str) -> None:
|
||||
p = subprocess.Popen(["ssh", NULLSTONE, f"cat > {shlex.quote(remote_path)}"],
|
||||
stdin=subprocess.PIPE)
|
||||
p.communicate(content)
|
||||
if p.returncode != 0:
|
||||
die(f"failed writing {remote_path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("series_id")
|
||||
ap.add_argument("--season", type=int, default=None)
|
||||
ap.add_argument("--start", type=int, default=1)
|
||||
ap.add_argument("--end", type=int, default=10**6)
|
||||
ap.add_argument("--all", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.season is None and not args.all:
|
||||
die("pass --season N or --all")
|
||||
|
||||
api_key = load_api_key()
|
||||
dry = os.environ.get("DRY_RUN") == "1"
|
||||
|
||||
eps = list_episodes(args.series_id)
|
||||
work = []
|
||||
for ep in eps:
|
||||
s, n = ep["ParentIndexNumber"], ep["IndexNumber"]
|
||||
if not args.all and s != args.season:
|
||||
continue
|
||||
if not (args.start <= n <= args.end):
|
||||
continue
|
||||
work.append(ep)
|
||||
if not work:
|
||||
die("no episodes selected")
|
||||
|
||||
print(f"[plan] {len(work)} episodes selected", file=sys.stderr)
|
||||
|
||||
ok = 0
|
||||
fail = []
|
||||
for ep in work:
|
||||
s, n = ep["ParentIndexNumber"], ep["IndexNumber"]
|
||||
label = f"libS{s:02}E{n:02} {ep['Name']}"
|
||||
|
||||
imdb = imdb_strip(ep.get("ProviderIds", {}).get("Imdb"))
|
||||
if not imdb:
|
||||
print(f"[skip] {label} — no IMDB id", file=sys.stderr)
|
||||
fail.append((label, "no-imdb"))
|
||||
continue
|
||||
|
||||
try:
|
||||
fox = os_search_imdb(api_key, imdb)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"[skip] {label} — OS search err {e.returncode}", file=sys.stderr)
|
||||
fail.append((label, "os-search"))
|
||||
continue
|
||||
if fox is None:
|
||||
print(f"[skip] {label} — OS has no S/E for imdb={imdb}", file=sys.stderr)
|
||||
fail.append((label, "no-fox-se"))
|
||||
continue
|
||||
fox_s, fox_e = fox
|
||||
|
||||
# series name + year — pull from path or item
|
||||
series_name = ep.get("SeriesName") or "Show"
|
||||
year = None
|
||||
ymatch = re.search(r"\((\d{4})\)", ep.get("Path", ""))
|
||||
if ymatch:
|
||||
year = int(ymatch.group(1))
|
||||
|
||||
v_name = addic7ed_safe_name(series_name, year, fox_s, fox_e)
|
||||
v = Video.fromname(v_name)
|
||||
|
||||
try:
|
||||
hits = list_subtitles([v], {Language("eng")},
|
||||
providers=["addic7ed"]).get(v, [])
|
||||
except Exception as e:
|
||||
print(f"[skip] {label} — addic7ed list err: {type(e).__name__}",
|
||||
file=sys.stderr)
|
||||
fail.append((label, "a7d-list"))
|
||||
continue
|
||||
|
||||
if not hits:
|
||||
print(f"[skip] {label} — addic7ed 0 subs (foxS{fox_s:02}E{fox_e:02})",
|
||||
file=sys.stderr)
|
||||
fail.append((label, "a7d-no-hits"))
|
||||
continue
|
||||
|
||||
pick = hits[0] # subliminal returns ordered; take first
|
||||
print(f"[pick] {label} -> foxS{fox_s:02}E{fox_e:02} a7d={pick.id}",
|
||||
file=sys.stderr)
|
||||
|
||||
if dry:
|
||||
ok += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
download_subtitles([pick])
|
||||
except Exception as e:
|
||||
print(f"[fail] {label} — addic7ed dl err: {type(e).__name__}: {e}",
|
||||
file=sys.stderr)
|
||||
fail.append((label, "a7d-dl"))
|
||||
continue
|
||||
|
||||
if not pick.content:
|
||||
print(f"[fail] {label} — empty content", file=sys.stderr)
|
||||
fail.append((label, "empty"))
|
||||
continue
|
||||
|
||||
remote_dir, base = episode_to_paths(ep)
|
||||
dest = f"{remote_dir}/{base}.eng.srt"
|
||||
write_sidecar_remote(pick.content, dest)
|
||||
print(f"[ok] {label} -> {dest}", file=sys.stderr)
|
||||
ok += 1
|
||||
|
||||
print(f"\n[done] ok={ok}/{len(work)} failures={len(fail)}", file=sys.stderr)
|
||||
for lab, why in fail:
|
||||
print(f" - {lab}: {why}", file=sys.stderr)
|
||||
if ok:
|
||||
try:
|
||||
subprocess.run([os.path.join(os.path.dirname(__file__),
|
||||
"audit-coverage.py")],
|
||||
check=False)
|
||||
except Exception as e:
|
||||
print(f"[warn] coverage refresh skipped: {e}", file=sys.stderr)
|
||||
return 0 if ok else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
76
playbooks/subtitles/lib/sub-fetch.sh
Executable file
76
playbooks/subtitles/lib/sub-fetch.sh
Executable file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env bash
|
||||
# Subtitle fetch helper — recipe v1 Step 4.
|
||||
#
|
||||
# Single-episode loop body. Runs against a Jellyfin instance reachable from
|
||||
# nullstone via `docker exec jellyfin curl ...`. Driver loops should source or
|
||||
# call this per episode.
|
||||
#
|
||||
# Picker: highest DownloadCount among results that are NOT
|
||||
# (HearingImpaired|MachineTranslated|AiTranslated|Forced); 23.976fps preferred.
|
||||
# Falls back to all results if every candidate is HI/MT/AI/Forced.
|
||||
#
|
||||
# Side effects:
|
||||
# - POSTs RemoteSearch download (consumes 1 of 20 daily free-tier slots)
|
||||
# - docker cp's the resulting metadata-cache srt to MEDIA_DIR
|
||||
#
|
||||
# Caller env:
|
||||
# TOK Jellyfin admin X-Emby-Token
|
||||
# EP Jellyfin episode item id
|
||||
# MEDIA_DIR destination dir on nullstone, e.g.
|
||||
# '/home/user/media/tv/American Dad! (2005)/Season 01'
|
||||
# MEDIA_BASE filename without extension, must match the .mkv basename
|
||||
#
|
||||
# Exits non-zero on no-subs (1) or download HTTP != 204 (2).
|
||||
# Output to stdout: "OK <ep-id> -> <dest path>".
|
||||
# Output to stderr: chosen sub release name + fps + DownloadCount, or error.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${TOK:?TOK required}"
|
||||
: "${EP:?EP required}"
|
||||
: "${MEDIA_DIR:?MEDIA_DIR required}"
|
||||
: "${MEDIA_BASE:?MEDIA_BASE required}"
|
||||
|
||||
NULLSTONE="${NULLSTONE:-user@192.168.0.100}"
|
||||
|
||||
RAW=$(ssh "$NULLSTONE" "docker exec jellyfin curl -s -H 'X-Emby-Token: $TOK' \
|
||||
'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/eng'")
|
||||
|
||||
SUBID=$(printf '%s' "$RAW" | python3 -c "
|
||||
import json, sys
|
||||
subs = json.load(sys.stdin)
|
||||
clean = [s for s in subs
|
||||
if not (s.get('HearingImpaired') or s.get('MachineTranslated')
|
||||
or s.get('AiTranslated') or s.get('Forced'))]
|
||||
if not clean:
|
||||
clean = subs
|
||||
fps2398 = [s for s in clean if abs(s.get('FrameRate', 0) - 23.976) < 0.01]
|
||||
pool = fps2398 if fps2398 else clean
|
||||
pool.sort(key=lambda s: -s.get('DownloadCount', 0))
|
||||
if pool:
|
||||
print(pool[0]['Id'])
|
||||
print(pool[0]['Name'], pool[0].get('FrameRate'),
|
||||
pool[0].get('DownloadCount'), file=sys.stderr)
|
||||
")
|
||||
|
||||
if [[ -z "$SUBID" ]]; then
|
||||
echo "NO-SUBS for $EP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HTTP=$(ssh "$NULLSTONE" "docker exec jellyfin curl -s -o /dev/null -X POST \
|
||||
-H 'X-Emby-Token: $TOK' \
|
||||
'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/$SUBID' \
|
||||
-w '%{http_code}'")
|
||||
|
||||
if [[ "$HTTP" != "204" ]]; then
|
||||
echo "DL-FAIL HTTP=$HTTP for $EP $SUBID" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SHARD="${EP:0:2}"
|
||||
SRC_IN_CONTAINER="/config/metadata/library/$SHARD/$EP/$MEDIA_BASE.eng.srt"
|
||||
DEST="$MEDIA_DIR/$MEDIA_BASE.eng.srt"
|
||||
|
||||
ssh "$NULLSTONE" "docker cp \"jellyfin:$SRC_IN_CONTAINER\" \"$DEST\"" >/dev/null
|
||||
echo "OK $EP -> $DEST"
|
||||
292
playbooks/subtitles/lib/sub-rest-fetch.py
Executable file
292
playbooks/subtitles/lib/sub-rest-fetch.py
Executable file
|
|
@ -0,0 +1,292 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Subtitle fetcher v2 — direct OpenSubtitles REST API.
|
||||
|
||||
Bypasses the Jellyfin OpenSubtitles plugin to dodge season/episode numbering
|
||||
mismatches. Looks each library episode up by its per-episode IMDB id, picks
|
||||
the best English match, downloads via the REST endpoint, and writes the
|
||||
sidecar straight onto nullstone next to the media file (via SSH).
|
||||
|
||||
Why v2 exists: see ../CHANGELOG.md "Known break" — American Dad library
|
||||
uses Hulu season numbering, OS catalogues by Fox airing order; the plugin
|
||||
queries by (parent_imdb_id, season, episode) so library S02E01 → OS S01E08
|
||||
returned 0 hits even though the per-episode IMDB id (tt0511631) is real.
|
||||
|
||||
Picker: highest download_count among non-HI, non-MT, non-AI, non-Forced
|
||||
candidates; 23.976fps preferred. Falls back to all candidates if every match
|
||||
is HI/MT/AI/Forced.
|
||||
|
||||
Usage:
|
||||
sub-rest-fetch.py <series-id> --season <N> [--start <ep>] [--end <ep>]
|
||||
sub-rest-fetch.py <series-id> --all
|
||||
|
||||
Env (required):
|
||||
JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin
|
||||
OPENSUBTITLES_API_KEY Path to file holding the API key
|
||||
OPENSUBTITLES_USER OS account username
|
||||
OPENSUBTITLES_PASS OS account password
|
||||
|
||||
Env (optional):
|
||||
NULLSTONE SSH target, default user@192.168.0.100
|
||||
DRY_RUN=1 search + pick only, no download
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
OS_BASE = "https://api.opensubtitles.com/api/v1"
|
||||
USER_AGENT = "arrflix v1.0.0"
|
||||
JF_BASE = "http://localhost:8096"
|
||||
NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100")
|
||||
|
||||
|
||||
def die(msg: str, code: int = 1) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def env_or_die(name: str) -> str:
|
||||
v = os.environ.get(name)
|
||||
if not v:
|
||||
die(f"{name} not set")
|
||||
return v
|
||||
|
||||
|
||||
def load_api_key() -> str:
|
||||
path = env_or_die("OPENSUBTITLES_API_KEY")
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
def _curl(url: str, method: str = "GET", headers: dict | None = None,
|
||||
body: dict | None = None, binary: bool = False) -> bytes:
|
||||
"""OpenSubtitles' frontend rejects urllib (consistent 503 on /download).
|
||||
curl works against the same endpoint and headers. Use curl uniformly."""
|
||||
cmd = ["curl", "-sSf", "-X", method, url]
|
||||
for k, v in (headers or {}).items():
|
||||
cmd += ["-H", f"{k}: {v}"]
|
||||
if body is not None:
|
||||
cmd += ["--data", json.dumps(body)]
|
||||
return subprocess.check_output(cmd)
|
||||
|
||||
|
||||
def http_json(url: str, method: str = "GET", headers: dict | None = None,
|
||||
body: dict | None = None) -> dict:
|
||||
raw = _curl(url, method, headers, body)
|
||||
return json.loads(raw.decode())
|
||||
|
||||
|
||||
def http_get_bytes(url: str) -> bytes:
|
||||
return _curl(url, "GET", headers={"User-Agent": USER_AGENT})
|
||||
|
||||
|
||||
def jellyfin(path: str, params: dict | None = None) -> dict:
|
||||
"""Run Jellyfin API call inside the container on nullstone via SSH."""
|
||||
tok = env_or_die("JELLYFIN_TOKEN")
|
||||
qs = ""
|
||||
if params:
|
||||
qs = "?" + urllib.parse.urlencode(params, safe=",")
|
||||
url = JF_BASE + path + qs
|
||||
cmd = ["ssh", NULLSTONE,
|
||||
f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"]
|
||||
out = subprocess.check_output(cmd, text=True)
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def list_episodes(series_id: str) -> list[dict]:
|
||||
d = jellyfin(f"/Items", {
|
||||
"ParentId": series_id,
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,ParentIndexNumber,IndexNumber,ProviderIds",
|
||||
"SortBy": "ParentIndexNumber,IndexNumber",
|
||||
})
|
||||
return d["Items"]
|
||||
|
||||
|
||||
def os_login(api_key: str, user: str, password: str) -> str:
|
||||
res = http_json(f"{OS_BASE}/login", "POST", headers={
|
||||
"Api-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
}, body={"username": user, "password": password})
|
||||
return res["token"]
|
||||
|
||||
|
||||
def os_user_info(api_key: str, bearer: str) -> dict:
|
||||
return http_json(f"{OS_BASE}/infos/user", headers={
|
||||
"Api-Key": api_key,
|
||||
"Authorization": f"Bearer {bearer}",
|
||||
"User-Agent": USER_AGENT,
|
||||
})["data"]
|
||||
|
||||
|
||||
def os_search(api_key: str, imdb_id: str) -> list[dict]:
|
||||
"""imdb_id without the 'tt' prefix per OS convention."""
|
||||
res = http_json(
|
||||
f"{OS_BASE}/subtitles?imdb_id={imdb_id}&languages=en",
|
||||
headers={"Api-Key": api_key, "User-Agent": USER_AGENT})
|
||||
return res.get("data", [])
|
||||
|
||||
|
||||
def pick_best(hits: list[dict]) -> dict | None:
|
||||
"""Filter HI/MT/AI/Forced, prefer 23.976fps, sort by download_count desc."""
|
||||
def attr(h, k):
|
||||
return h["attributes"].get(k)
|
||||
|
||||
clean = [h for h in hits
|
||||
if not attr(h, "hearing_impaired")
|
||||
and not attr(h, "machine_translated")
|
||||
and not attr(h, "ai_translated")
|
||||
and not attr(h, "foreign_parts_only")]
|
||||
if not clean:
|
||||
clean = hits
|
||||
fps2398 = [h for h in clean if abs((attr(h, "fps") or 0) - 23.976) < 0.01]
|
||||
pool = fps2398 if fps2398 else clean
|
||||
pool.sort(key=lambda h: -(attr(h, "download_count") or 0))
|
||||
return pool[0] if pool else None
|
||||
|
||||
|
||||
def os_download(api_key: str, bearer: str, file_id: int) -> dict:
|
||||
return http_json(f"{OS_BASE}/download", "POST", headers={
|
||||
"Api-Key": api_key,
|
||||
"Authorization": f"Bearer {bearer}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
}, body={"file_id": file_id})
|
||||
|
||||
|
||||
def write_sidecar_remote(content: bytes, remote_path: str) -> None:
|
||||
"""ssh redirect file content to nullstone."""
|
||||
cmd = ["ssh", NULLSTONE, f"cat > {shlex.quote(remote_path)}"]
|
||||
p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||
p.communicate(content)
|
||||
if p.returncode != 0:
|
||||
die(f"failed writing {remote_path}")
|
||||
|
||||
|
||||
def imdb_strip(s: str | None) -> str | None:
|
||||
if not s:
|
||||
return None
|
||||
return s[2:] if s.startswith("tt") else s
|
||||
|
||||
|
||||
def episode_to_paths(ep: dict) -> tuple[str, str]:
|
||||
"""Return (remote_dir, base_filename) for sidecar placement."""
|
||||
container_path = ep["Path"] # /media/tv/Show/Season XX/Show - SxxExx - Title.mkv
|
||||
host_path = container_path.replace("/media/", "/home/user/media/")
|
||||
remote_dir = os.path.dirname(host_path)
|
||||
base = os.path.splitext(os.path.basename(host_path))[0]
|
||||
return remote_dir, base
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("series_id")
|
||||
ap.add_argument("--season", type=int, default=None)
|
||||
ap.add_argument("--start", type=int, default=1)
|
||||
ap.add_argument("--end", type=int, default=10**6)
|
||||
ap.add_argument("--all", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.season is None and not args.all:
|
||||
die("pass --season N or --all")
|
||||
|
||||
api_key = load_api_key()
|
||||
user = env_or_die("OPENSUBTITLES_USER")
|
||||
pw = env_or_die("OPENSUBTITLES_PASS")
|
||||
dry = os.environ.get("DRY_RUN") == "1"
|
||||
|
||||
bearer = os_login(api_key, user, pw)
|
||||
info = os_user_info(api_key, bearer)
|
||||
print(f"[quota] remaining={info['remaining_downloads']}/{info['allowed_downloads']}, "
|
||||
f"resets in {info['reset_time']}", file=sys.stderr)
|
||||
|
||||
eps = list_episodes(args.series_id)
|
||||
work = []
|
||||
for ep in eps:
|
||||
s = ep["ParentIndexNumber"]
|
||||
n = ep["IndexNumber"]
|
||||
if not args.all and s != args.season:
|
||||
continue
|
||||
if not (args.start <= n <= args.end):
|
||||
continue
|
||||
work.append(ep)
|
||||
if not work:
|
||||
die("no episodes selected")
|
||||
|
||||
print(f"[plan] {len(work)} episodes selected", file=sys.stderr)
|
||||
if not dry and len(work) > info["remaining_downloads"]:
|
||||
print(f"[warn] {len(work)} > quota {info['remaining_downloads']}; "
|
||||
f"will halt mid-run", file=sys.stderr)
|
||||
|
||||
ok = 0
|
||||
fail = []
|
||||
for ep in work:
|
||||
s, n = ep["ParentIndexNumber"], ep["IndexNumber"]
|
||||
label = f"S{s:02}E{n:02} {ep['Name']}"
|
||||
imdb = imdb_strip(ep.get("ProviderIds", {}).get("Imdb"))
|
||||
if not imdb:
|
||||
print(f"[skip] {label} — no IMDB id", file=sys.stderr)
|
||||
fail.append((label, "no-imdb"))
|
||||
continue
|
||||
|
||||
hits = os_search(api_key, imdb)
|
||||
pick = pick_best(hits)
|
||||
if not pick:
|
||||
print(f"[skip] {label} — 0 hits for imdb={imdb}", file=sys.stderr)
|
||||
fail.append((label, "no-hits"))
|
||||
continue
|
||||
|
||||
a = pick["attributes"]
|
||||
f = a["files"][0]
|
||||
print(f"[pick] {label} imdb={imdb} fid={f['file_id']} dl={a.get('download_count')} "
|
||||
f"fps={a.get('fps')} fname={f.get('file_name')}", file=sys.stderr)
|
||||
|
||||
if dry:
|
||||
ok += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
dl = os_download(api_key, bearer, f["file_id"])
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"[fail] {label} download (curl exit {e.returncode})", file=sys.stderr)
|
||||
fail.append((label, f"dl-curl-{e.returncode}"))
|
||||
break # may be quota; stop run
|
||||
|
||||
link = dl.get("link")
|
||||
if not link:
|
||||
print(f"[fail] {label} no download link in response: {dl}", file=sys.stderr)
|
||||
fail.append((label, "no-link"))
|
||||
break
|
||||
|
||||
content = http_get_bytes(link)
|
||||
remote_dir, base = episode_to_paths(ep)
|
||||
dest = f"{remote_dir}/{base}.eng.srt"
|
||||
write_sidecar_remote(content, dest)
|
||||
print(f"[ok] {label} -> {dest} (remaining={dl.get('remaining')})",
|
||||
file=sys.stderr)
|
||||
ok += 1
|
||||
time.sleep(0.5) # be polite
|
||||
|
||||
print(f"\n[done] ok={ok}/{len(work)} failures={len(fail)}", file=sys.stderr)
|
||||
for lab, why in fail:
|
||||
print(f" - {lab}: {why}", file=sys.stderr)
|
||||
if ok:
|
||||
try:
|
||||
subprocess.run([os.path.join(os.path.dirname(__file__),
|
||||
"audit-coverage.py")],
|
||||
check=False)
|
||||
except Exception as e:
|
||||
print(f"[warn] coverage refresh skipped: {e}", file=sys.stderr)
|
||||
return 0 if ok else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
68
playbooks/subtitles/lib/sub-yt-fetch.sh
Executable file
68
playbooks/subtitles/lib/sub-yt-fetch.sh
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env bash
|
||||
# Subtitle fetcher v3.5 — YouTube auto-captions via yt-dlp + cleaner.
|
||||
#
|
||||
# For shows that distribute on YouTube and have no community subs anywhere
|
||||
# else (e.g. Big Lez Show universe: Sassy the Sasquatch, Donny & Clarence,
|
||||
# Mike Nolan, Big Lez Saga). yt-dlp pulls the en-orig auto-CC track, the
|
||||
# rolling-window VTT goes through yt-clean.py to deduplicate into a flat
|
||||
# SRT, and the result is dropped on nullstone with the library filename.
|
||||
#
|
||||
# Quality caveats (per playbooks/subtitles/STYLE.md fallback policy):
|
||||
# - lowercase, no punctuation
|
||||
# - YouTube ASR mishears proper nouns (e.g. "Sassy" → "sasha")
|
||||
# - profanity is censored as "[ __ ]"
|
||||
# - capitalisation / sentence segmentation is absent
|
||||
#
|
||||
# These subs ship as a stop-gap. v4 (WhisperX large-v3 on the 4080 friend
|
||||
# node) replaces them with full-quality transcriptions; see ROADMAP.
|
||||
#
|
||||
# Usage:
|
||||
# sub-yt-fetch.sh <playlist-or-channel-url> <out-dir> <name-template>
|
||||
#
|
||||
# Example (Sassy):
|
||||
# sub-yt-fetch.sh \
|
||||
# 'https://www.youtube.com/playlist?list=PLGMC7oz7XpmDMGrALMQiNXCi9p7aqkWbj' \
|
||||
# /tmp/sassy-yt \
|
||||
# 'Sassy the Sasquatch (2022) - S01E%(playlist_index)02d - %(title)s'
|
||||
#
|
||||
# After fetch: rename / copy each .en.srt to nullstone with the canonical
|
||||
# library filename (`<videobasename>.eng.srt`). For now this is manual —
|
||||
# automate when the next show comes through.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PLAYLIST="${1:?playlist or channel URL required}"
|
||||
OUTDIR="${2:?output directory required}"
|
||||
NAMETMPL="${3:-S%(playlist_index)02d - %(title)s}"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
if ! command -v yt-dlp >/dev/null; then
|
||||
echo "ERROR: yt-dlp not installed (pip install yt-dlp)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull raw VTT auto-CC, no video, en-orig only (matches en bytewise but is the
|
||||
# canonical track to request).
|
||||
yt-dlp --skip-download --write-auto-subs --sub-langs "en-orig" \
|
||||
--sub-format vtt \
|
||||
--sleep-requests 1 --sleep-subtitles 2 \
|
||||
-o "$OUTDIR/${NAMETMPL}-raw.%(ext)s" \
|
||||
"$PLAYLIST"
|
||||
|
||||
CLEANER="$(dirname "$0")/yt-clean.py"
|
||||
if [[ ! -x "$CLEANER" ]]; then
|
||||
echo "ERROR: $CLEANER not found / not executable" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Convert each raw VTT to clean SRT
|
||||
shopt -s nullglob
|
||||
for vtt in "$OUTDIR"/*-raw.en-orig.vtt; do
|
||||
out="${vtt%-raw.en-orig.vtt}.en.srt"
|
||||
python3 "$CLEANER" "$vtt" "$out"
|
||||
echo "OK $out"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "next: copy each .en.srt to nullstone with library filename, then library scan."
|
||||
56
playbooks/subtitles/lib/yt-clean.py
Executable file
56
playbooks/subtitles/lib/yt-clean.py
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Clean YouTube auto-caption VTT into a flat SRT with no rolling-window dupes."""
|
||||
import re, sys, pathlib
|
||||
|
||||
def parse_vtt(text):
|
||||
"""Yield (start, end, line) tuples, dropping inline timing tags and empty lines."""
|
||||
blocks = re.split(r'\n\n+', text.strip())
|
||||
for b in blocks:
|
||||
if 'WEBVTT' in b or b.startswith('Kind:') or b.startswith('Language:'):
|
||||
continue
|
||||
m = re.search(r'(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})', b)
|
||||
if not m: continue
|
||||
start, end = m.group(1), m.group(2)
|
||||
# Strip cue settings and inline <00:..><c>...</c> tags
|
||||
body = b[m.end():].strip()
|
||||
body = re.sub(r'<\d{2}:\d{2}:\d{2}\.\d{3}>', '', body)
|
||||
body = re.sub(r'</?c[^>]*>', '', body)
|
||||
body = re.sub(r'align:\S+|position:\S+', '', body).strip()
|
||||
# Last non-empty line is "new" content (rolling window puts the freshly spoken line at bottom)
|
||||
lines = [ln.strip() for ln in body.split('\n') if ln.strip()]
|
||||
if not lines: continue
|
||||
yield start, end, lines[-1]
|
||||
|
||||
def to_srt_time(t):
|
||||
return t.replace('.', ',')
|
||||
|
||||
def merge(events):
|
||||
"""Drop the 10ms 'gap' cues and merge consecutive identical text."""
|
||||
out = []
|
||||
for s, e, txt in events:
|
||||
# Skip the bridge cue with same text already on top
|
||||
if out and out[-1][2] == txt:
|
||||
out[-1] = (out[-1][0], to_srt_time(e), txt) # extend
|
||||
continue
|
||||
out.append([to_srt_time(s), to_srt_time(e), txt])
|
||||
# second pass to drop micro-cues
|
||||
final = []
|
||||
for s, e, txt in out:
|
||||
sh, sm, ssms = s.split(':'); ssec, sms = ssms.split(',')
|
||||
eh, em, esms = e.split(':'); esec, ems = esms.split(',')
|
||||
sm_total = int(sh)*3600+int(sm)*60+int(ssec)+int(sms)/1000
|
||||
em_total = int(eh)*3600+int(em)*60+int(esec)+int(ems)/1000
|
||||
if em_total - sm_total < 0.05: continue # 50ms bridge cue
|
||||
final.append((s, e, txt))
|
||||
return final
|
||||
|
||||
def write_srt(events, path):
|
||||
with open(path, 'w') as f:
|
||||
for i, (s, e, txt) in enumerate(events, 1):
|
||||
f.write(f"{i}\n{s} --> {e}\n{txt}\n\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
vtt = pathlib.Path(sys.argv[1]).read_text()
|
||||
events = list(merge(parse_vtt(vtt)))
|
||||
write_srt(events, sys.argv[2])
|
||||
print(f"wrote {len(events)} cues -> {sys.argv[2]}")
|
||||
37
playbooks/subtitles/runs/_template.md
Normal file
37
playbooks/subtitles/runs/_template.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Subtitle run — `<Show name (Year)>`
|
||||
|
||||
Recipe version: v?
|
||||
Run date: YYYY-MM-DD
|
||||
Operator: Claude Code @ <session>
|
||||
Quota at start / end: ?? / ??
|
||||
|
||||
## Source
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Episodes | ?? (S01–S??) |
|
||||
| Container | mkv / mp4 / ... |
|
||||
| Video | codec res fps |
|
||||
| Audio | language tag(s) |
|
||||
| Embedded subs | yes / no — codecs |
|
||||
| Existing sidecars | yes / no |
|
||||
|
||||
## Outcome
|
||||
|
||||
| Season | Eps | Subs fetched | Quality sample | Notes |
|
||||
|---|---|---|---|---|
|
||||
| S01 | ? | ? / ? | ? | |
|
||||
|
||||
## Picks (sample)
|
||||
|
||||
| Episode | Sub Id | Author | DownloadCount | FrameRate | HI |
|
||||
|---|---|---|---|---|---|
|
||||
| S01E01 | ... | ... | ... | ... | ... |
|
||||
|
||||
## Breakage (if any)
|
||||
|
||||
What broke, what was probed, what the recipe should have done differently.
|
||||
|
||||
## Recipe amendments triggered
|
||||
|
||||
- v1 → v2: ...
|
||||
110
playbooks/subtitles/runs/american-dad.md
Normal file
110
playbooks/subtitles/runs/american-dad.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Subtitle run — `American Dad! (2005)`
|
||||
|
||||
Recipe version: v1 (S01) → v2 (S02E01–E12) → v3 Addic7ed (S02E13–E16, S03, S04)
|
||||
Run date: 2026-05-09
|
||||
Operator: Claude Code @ onyx session, ai-lab cwd
|
||||
OS REST quota usage: 20 → 1 (19 downloads, quota-counted)
|
||||
Addic7ed downloads: 30 (anonymous, no daily cap)
|
||||
|
||||
## Source
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Episodes | 58 (S01=7, S02=16, S03=19, S04=16) |
|
||||
| Container | mkv |
|
||||
| Video | HEVC Main10, 1440×1080, 23.98 fps, 4:3 SAR 1:1 |
|
||||
| Audio | `eng` AAC stereo (default) + `eng` AC3 5.1 |
|
||||
| Embedded subs | none |
|
||||
| Existing sidecars | none |
|
||||
|
||||
Library uses Hulu/DSP season ordering (S1=7 eps). Original Fox order has S1=23 eps.
|
||||
|
||||
## Series + library context
|
||||
|
||||
- Series Id: `3b3bc999e9107f1a7643ac45d6427fee`
|
||||
- Library: `767bffe4f11c93ef34b805451a696a4e` (TV Shows, `/media/tv`)
|
||||
- Library options: `SaveSubtitlesWithMedia=true`, `SubtitleDownloadLanguages=["eng"]`, `RequirePerfectSubtitleMatch=false` ✓
|
||||
- Plugin: Open Subtitles v20.0.0.0, Active, creds `Caveman5` valid
|
||||
|
||||
## Outcome
|
||||
|
||||
| Season | Eps | Subs fetched | Quality sample | Notes |
|
||||
|---|---|---|---|---|
|
||||
| S01 | 7 | 7 / 7 | not yet visually verified by playback (TODO) | v1 plugin path. OMiCRON DVDRip 23.976fps |
|
||||
| S02 | 16 | 16 / 16 | S02E16 first lines confirmed match episode | E01-E12 v2 OS REST (mixed OMiCRON + 20FOX); E13-E16 v3 Addic7ed (no quota cost) |
|
||||
| S03 | 19 | 16 / 19 | not yet visually verified | v3 Addic7ed. Misses: E04 Lincoln Lover (a7d 0 subs), E13 Black Mystery Month (a7d empty body), E19 Joint Custody (a7d 0 subs) |
|
||||
| S04 | 16 | 10 / 16 | not yet visually verified | v3 Addic7ed. Misses: E01-E05 (Vacation Goo / Meter Made / Dope & Faith / Big Trouble in Little Langley / Haylias) and E11 Oedipal Panties — all "a7d 0 subs" for the OS-feat-details S/E we passed |
|
||||
|
||||
Net: **49 / 58 (84 %)**.
|
||||
|
||||
Remaining 9 episodes can land via OS REST tomorrow (20-quota window covers them all in one batch).
|
||||
|
||||
## Picks (S01)
|
||||
|
||||
| Episode | Sub release | Author | DLs | FPS | HI |
|
||||
|---|---|---|---|---|---|
|
||||
| S01E01 Pilot | `American.Dad.S01E01.DVDRip.XviD.REPACK-OMiCRON` | zetakoo_ | 154 132 | 23.976 | no |
|
||||
| S01E02 Threat Levels | `American.Dad.S01E02.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 89 896 | 23.976 | no |
|
||||
| S01E03 Stan Knows Best | `American.Dad.S01E03.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 69 317 | 23.976 | no |
|
||||
| S01E04 Francines Flashback | `American.Dad.S01E04.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 72 315 | 23.976 | no |
|
||||
| S01E05 Roger Codger | `American.Dad.S01E05.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 32 309 | 23.976 | no |
|
||||
| S01E06 Homeland Insecurity | `American.Dad.S01E06.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 67 778 | 23.976 | no |
|
||||
| S01E07 Deacon Stan Jesus Man | `American.Dad.S01E07.DVDRip.XviD-OMiCRON` | (auto) | 65 124 | 24 | no |
|
||||
|
||||
All chose by recipe Step 4 picker (highest DownloadCount among non-HI / non-MT
|
||||
/ non-AI / non-Forced, prefer 23.976 fps). Picker behaved consistently — no
|
||||
manual override needed for S01.
|
||||
|
||||
## Breakage
|
||||
|
||||
After S01 passed, S02E01 search returned 0 results. Verified:
|
||||
|
||||
- ProviderIds for S02E01 in library = `Imdb=tt0511631 Tvdb=306168` (correct for "Bullocks to Stan")
|
||||
- Plugin quota: 13 / 20 remaining (not exhausted)
|
||||
- Plugin log shows no error — silent zero
|
||||
- Same recipe worked 7 times in a row immediately prior — not a script bug
|
||||
- Sample-tested S02E02 / S02E08 / S02E13 → all 0 results
|
||||
|
||||
Root cause: library numbering is Hulu/DSP (S1=7), OpenSubtitles indexes Fox
|
||||
airing order (S1=23). Plugin queries OS with `(parent_imdb_id, season,
|
||||
episode)` so library `S=2 E=1` maps to a Fox cell that doesn't exist on OS
|
||||
in that S/E slot, even though the per-episode IMDB id (`tt0511631`) is real
|
||||
and indexed on OS by Fox order as `S=1 E=8`.
|
||||
|
||||
The plugin doesn't expose per-episode-IMDB lookup, only the S/E combo path,
|
||||
so there's no flag we can flip to make this work.
|
||||
|
||||
## Recipe amendments triggered
|
||||
|
||||
- **v1 → v2**: process needs a season-numbering pre-check (Step 3), and a
|
||||
fallback fetch path that doesn't rely on plugin S/E mapping. See
|
||||
`CHANGELOG.md` v2 design choice between direct OS REST (recommended) and
|
||||
library re-numbering.
|
||||
|
||||
## v2 picks (S02E01–E12)
|
||||
|
||||
| Episode | Sub release | DLs | FPS | HI |
|
||||
|---|---|---|---|---|
|
||||
| S02E01 Bullocks to Stan | `american.dad.s01e08.dvdrip.xvid-omicron` | 25 846 | 23.976 | no |
|
||||
| S02E02 A Smith in the Hand | `American Dad S01E09 A Smith in the Hand.DVDRip.NonHI.cc.en.20FOX` | 75 | 29.97 | no |
|
||||
| S02E03 All About Steve | `American Dad S01E10 All About Steve.DVDRip.NonHI.cc.en.20FOX` | 2 600 | 29.97 | no |
|
||||
| S02E04 Con Heir | `American Dad S01E11 Con Heir.DVDRip.NonHI.cc.en.20FOX` | 140 | 29.97 | no |
|
||||
| S02E05 Stan of Arabia 1 | `American Dad S01E12 Stan of Arabia Part 1.DVDRip.NonHI.cc.en.20FOX` | 110 | 29.97 | no |
|
||||
| S02E06 Stan of Arabia 2 | `American Dad S01E13 Stan of Arabia Part 2.DVDRip.NonHI.cc.en.20FOX` | 86 | 29.97 | no |
|
||||
| S02E07 Stannie Get Your Gun | `American Dad S01E14 Stannie Get Your Gun.DVDRip.NonHI.cc.en.20FOX` | 99 | 29.97 | no |
|
||||
| S02E08 Star Trek | `American Dad [2.15]` | 18 | 0.0 | no |
|
||||
| S02E09 Not Particularly Desperate USER-Gwives | `American Dad [2.16]` | 24 | 0.0 | no |
|
||||
| S02E10 Rough Trade | `American Dad S01E17 Rough Trade.DVDRip.NonHI.cc.en.20FOX` | 40 | 29.97 | no |
|
||||
| S02E11 Finances With Wolves | `American Dad [1.18] Finances with Wolves-eng` | 7 730 | 23.976 | no |
|
||||
| S02E12 It's Good to be the Queen | `American Dad - 1x19 - Its Good to be the Queen.en` | 13 228 | 23.976 | no |
|
||||
|
||||
Note: 8 picks are 29.97 fps. SRT timestamps are absolute time, so this should
|
||||
not desync on a 23.976 fps source provided NTSC durations match. Confirm via
|
||||
recipe Step 6 sync sample on at least one 29.97-pick episode.
|
||||
|
||||
## Followups
|
||||
|
||||
- [ ] visually verify sample S01 sub plays in sync (recipe §6)
|
||||
- [ ] visually verify sample S02 29.97-fps pick plays in sync (e.g. S02E03)
|
||||
- [ ] visually verify sample Addic7ed pick plays in sync (e.g. S03E01 or S04E10)
|
||||
- [ ] tomorrow (after 23:59 UTC quota reset): rerun `sub-rest-fetch.py --season N --start E --end E` on the 9 missed eps via OS REST
|
||||
103
playbooks/subtitles/runs/sassy-the-sasquatch.md
Normal file
103
playbooks/subtitles/runs/sassy-the-sasquatch.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Subtitle run — `Sassy the Sasquatch (2022)`
|
||||
|
||||
> ⚠ **STOP-GAP — needs v4 WhisperX cross-ref.** Owner accepted current
|
||||
> subs as "85 %, acceptable" but tracked for full rebuild when v4 lands
|
||||
> (ROADMAP H5). See [`STOPGAP-SUBS.md`](../STOPGAP-SUBS.md).
|
||||
|
||||
Recipe version: v3.5 — YouTube auto-CC via yt-dlp + cleaner (v4 WhisperX planned, see ROADMAP)
|
||||
Run date: 2026-05-10
|
||||
Operator: Claude Code @ onyx session, ai-lab cwd
|
||||
|
||||
## Source
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Episodes | 5 (S01 only) |
|
||||
| Container | mkv |
|
||||
| Video | AV1 Main, 1920×1080, 29.97 fps |
|
||||
| Audio | `eng` Opus stereo (default) |
|
||||
| Embedded subs | none (only font / cover-art attachments) |
|
||||
| Existing sidecars | none |
|
||||
| Runtime | ~11:20 per episode |
|
||||
| Distribution | YouTube (THE BIG LEZ SHOW OFFICIAL channel, creator: Jarrad Wright) |
|
||||
|
||||
Niche-show indie animation. Same channel hosts Donny & Clarence Show, Mike
|
||||
Nolan Show, Big Lez Saga — all four shows in our library are Jarrad Wright
|
||||
productions distributed YouTube-first.
|
||||
|
||||
## Series + library context
|
||||
|
||||
- Series Id: `b2d1afd8a4a30c59adb42ccaf47376c2`
|
||||
- Library: `767bffe4f11c93ef34b805451a696a4e` (TV Shows, `/media/tv`)
|
||||
- IMDB series: `tt21209936`
|
||||
- TVDB series: `421839`
|
||||
- Per-episode IMDB ids: only S01E01 (`tt21215354`) — rest blank in TVDB
|
||||
|
||||
## Coverage probe — paid + free providers
|
||||
|
||||
Three parallel research agents (2026-05-10) checked every realistic source
|
||||
before falling back to YouTube:
|
||||
|
||||
| Provider | Hits |
|
||||
|---|---|
|
||||
| OpenSubtitles.com REST (`parent_imdb_id=21209936`) | 1 — `SASSY THE SASQUATCH.Web-DL.1080p.en` S01E01, **HI-flagged** |
|
||||
| OpenSubtitles.org legacy XML-RPC | 0 (account login 401 anyway) |
|
||||
| Addic7ed | 0 |
|
||||
| SubDL | 0 (`subtitles_count: 0`) |
|
||||
| SubSource (Subscene successor) | 0 |
|
||||
| Podnapisi | 0 |
|
||||
| OS VIP upgrade | **would not unlock anything** — VIP is download-cap relief, not coverage. Same catalog as free. |
|
||||
|
||||
Conclusion: nothing exists outside YouTube. Buying VIP would not help; the
|
||||
honest path is auto-generated subs.
|
||||
|
||||
## Outcome
|
||||
|
||||
| Season | Eps | Subs fetched | Quality | Notes |
|
||||
|---|---|---|---|---|
|
||||
| S01 | 5 | 5 / 5 | YT auto-CC stop-gap (lowercase, no punctuation, names mangled) | Cleaned via `lib/yt-clean.py`. v4 WhisperX rebuild planned |
|
||||
|
||||
Net: **5 / 5 (100 %)** — but at the lowest tier of the USER-G quality bar.
|
||||
|
||||
## Pipeline used
|
||||
|
||||
1. `yt-dlp --skip-download --write-auto-subs --sub-langs en-orig` against
|
||||
the official Sassy playlist (`PLGMC7oz7XpmDMGrALMQiNXCi9p7aqkWbj`) →
|
||||
raw VTT per episode in `/tmp/sassy-research/`.
|
||||
2. `lib/yt-clean.py` collapses the rolling-window VTT (each cue carries 2-3
|
||||
stale lines plus the freshly-spoken bottom line) into deduplicated SRT.
|
||||
3. SSH cat redirect each cleaned `.srt` to nullstone at
|
||||
`/home/user/media/tv/Sassy the Sasquatch (2022)/Season 01/<base>.eng.srt`
|
||||
with library filename.
|
||||
4. Validation-only library refresh; verified all 5 eps show exactly 1
|
||||
external eng sub stream.
|
||||
|
||||
Reusable pipeline now lives at `lib/sub-yt-fetch.sh` (wrapper) +
|
||||
`lib/yt-clean.py` (cleaner). Same one-liner handles Donny & Clarence,
|
||||
Mike Nolan, Big Lez Saga (all on the same channel).
|
||||
|
||||
## Quality known issues
|
||||
|
||||
- **Lowercase, no punctuation** — YT ASR output verbatim
|
||||
- **Proper-noun mishears**: "Sassy" → `sasha`, "Big Lez" → `Big Less`
|
||||
- **Profanity censored as `[ __ ]`** — passthrough from YT
|
||||
- **Sentence segmentation absent** — cues split on word boundaries
|
||||
|
||||
These violate STYLE.md "best quality" and "clean" rules. Documented as
|
||||
explicit stop-gap; v4 WhisperX rebuild restores quality bar.
|
||||
|
||||
## Mike Nolan special-case (deferred)
|
||||
|
||||
A YouTube upload titled "MIKE NOLAN SHOW | COMPLETE SEASON | SUBTITLES"
|
||||
posted Oct 2025 carries hand-typed CC tracks. When subbing Mike Nolan,
|
||||
prefer that single video (rip CC tracks) over the per-episode auto-CC
|
||||
playlist path. Note added to v4 roadmap.
|
||||
|
||||
## Followups
|
||||
|
||||
- [ ] visually verify one Sassy episode plays in sync (recipe §6) — YT
|
||||
auto-cap timing is usually tight but worth a sanity check
|
||||
- [ ] when v4 WhisperX lands, regenerate Sassy + Donny & Clarence + Big
|
||||
Lez Saga + Mike Nolan in one batch on the 4080 friend node
|
||||
- [ ] for Mike Nolan, try the "COMPLETE SEASON | SUBTITLES" YT upload
|
||||
before falling back to Whisper
|
||||
File diff suppressed because one or more lines are too long
74
testing/DEPLOY.md
Normal file
74
testing/DEPLOY.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# DEPLOY — dev → prod promotion workflow
|
||||
|
||||
> Strict order. Skip a step → break prod.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] testing/SMOKE-TEST.md passed on dev
|
||||
- [ ] You can name the change in one sentence
|
||||
- [ ] You have a rollback target (the current prod md5 — capture before deploy)
|
||||
|
||||
## Step 1 — capture current prod md5 (rollback anchor)
|
||||
|
||||
```bash
|
||||
PROD_BEFORE=$(ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html' | awk '{print $1}')
|
||||
echo "rollback anchor: $PROD_BEFORE"
|
||||
```
|
||||
|
||||
If anything goes wrong: `cp /opt/docker/jellyfin/web-overrides/index.html.bak.<latest> /opt/docker/jellyfin/web-overrides/index.html` + restart.
|
||||
|
||||
## Step 2 — backup prod overlay + branding
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'set -e
|
||||
TS=$(date +%s)
|
||||
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine cp /d/index.html /d/index.html.bak.deploy.$TS
|
||||
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine cp /d/branding.xml /d/branding.xml.bak.deploy.$TS
|
||||
'
|
||||
```
|
||||
|
||||
## Step 3 — copy dev overlay to prod via docker shim (root-owned dest)
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'docker run --rm --userns=host -v /opt/docker/jellyfin-dev/web-overrides:/dev:ro -v /opt/docker/jellyfin/web-overrides:/prod:rw alpine sh -c "cp /dev/index-dev.html /prod/index.html && chown root:root /prod/index.html && md5sum /prod/index.html"'
|
||||
```
|
||||
|
||||
## Step 4 — bind-mount inode swap requires restart
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'docker restart jellyfin && sleep 14'
|
||||
```
|
||||
|
||||
## Step 5 — verify
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html' # should match dev's md5
|
||||
ssh user@nullstone 'docker exec jellyfin curl -s http://127.0.0.1:8096/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN' # = 1
|
||||
ssh user@nullstone 'docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c' # ~36000
|
||||
ssh user@nullstone 'docker ps --format "{{.Names}} {{.Status}}" | grep "^jellyfin "' # healthy
|
||||
```
|
||||
|
||||
## Step 6 — manual smoke on prod
|
||||
|
||||
Open arrflix.s8n.ru in incognito. Run testing/SMOKE-TEST.md manual checklist.
|
||||
|
||||
## Step 7 — commit + push to repo
|
||||
|
||||
```bash
|
||||
cd /tmp/arrflix-recon
|
||||
cp web-overrides/index.html snapshots/2026-05-09-v6-stable/index.html # update snapshot
|
||||
git add bin/inject-middle-theme.py web-overrides/index.html snapshots/2026-05-09-v6-stable/index.html docs/ # whatever changed
|
||||
git -c user.name=s8n -c user.email=admin@s8n.ru commit -m "<one-line summary>"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## If verify fails
|
||||
|
||||
Run `testing/ROLLBACK.md` immediately. Don't try to fix forward on prod.
|
||||
|
||||
## Common deploy gotchas
|
||||
|
||||
- Forget `docker restart jellyfin` after `cp` → bind-mount inode swap → container serves stale
|
||||
- Forget `chown root:root` → user can't write but root needs to own per host config
|
||||
- Forget snapshot bump → next "what's deployed" question gets wrong answer
|
||||
- Forget commit → repo drifts from prod (per doc 26 INC1 root cause)
|
||||
215
testing/ERROR-PATTERNS.md
Normal file
215
testing/ERROR-PATTERNS.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# ERROR-PATTERNS — recurring theme/deploy/playback bugs
|
||||
|
||||
> Bookmark this. Every pattern below has happened multiple times. Read before debugging.
|
||||
|
||||
## Index of past errors
|
||||
|
||||
| # | Pattern | First seen | Last seen | Recurrences |
|
||||
|---|---------|------------|-----------|-------------|
|
||||
| 1 | Black screen over video (CSS overlay) | 2026-05-09 INC1 | 2026-05-10 image-12 | 6+ |
|
||||
| 2 | Video covers OSD controls (z-index too high) | 2026-05-10 image-12 | 2026-05-10 image-12 | 1 |
|
||||
| 3 | Bind-mount inode swap (container serves stale) | 2026-05-09 backHide | 2026-05-10 v4-selector | 3+ |
|
||||
| 4 | branding.xml XML parse silent fail | 2026-05-09 v6-stable | 2026-05-09 v6-stable | 1 |
|
||||
| 5 | test/123 password nuked after docker cp | 2026-05-09 multi | 2026-05-10 multi | 5+ |
|
||||
| 6 | DB readonly after docker cp (uid 101000) | 2026-05-09 multi | 2026-05-10 multi | 4+ |
|
||||
| 7 | camelCase class typo (.htmlVideoPlayer) | 2026-05-09 a6cf925 | 2026-05-09 a6cf925 | 1 |
|
||||
| 8 | Wrong Jellyfin class assumption | 2026-05-09 multi | 2026-05-10 multi | 3+ |
|
||||
| 9 | HDR10 grey wash (tonemap off) | 2026-05-08 doc 21 | 2026-05-09 fix | 1 |
|
||||
| 10 | Favicon clobbered by lockFavicon shim | 2026-05-09 favfix | 2026-05-09 favfix | 1 |
|
||||
| 11 | Backdrop residue / carousel black band | 2026-05-09 multi | 2026-05-10 multi | 2+ |
|
||||
| 12 | Quick Connect bypass + login mismatch | 2026-05-09 dev | 2026-05-09 dev | 1 |
|
||||
|
||||
## ERROR 1 — Black screen over video (CSS overlay)
|
||||
|
||||
**Symptom.** `<video>` decodes (`currentTime` advances, `readyState=4`, `videoWidth=1920`, `error=null`, `drawImage` luma >100) but viewport is opaque black. `darkPct=100%`. Doc 28: *"`<video>` is decoding actual pixels — yet a screenshot is all-black. Pixels never reach page composition."*
|
||||
|
||||
**Root cause.** Opaque `background-color` on a `<video>` ancestor while `body.arrflix-video-active` set OR `.htmlvideoplayer` in DOM. Offenders: `#videoOsdPage`, `.libraryPage`, `.layout-desktop`, `.pageContainer`, `.skinBody`, `.emby-scroller`.
|
||||
|
||||
**Diagnostic.** Probe DOM stack at video centre via `elementsFromPoint`; log `getComputedStyle(el).backgroundColor` per ancestor. `drawImage` luma >50 + screenshot all-black = overlay bug. See doc 28 §"Headless comparison".
|
||||
|
||||
**Fix.** Pair L1 (off-video opaque) / L2 (on-video transparent), scoped on body class:
|
||||
```css
|
||||
body.arrflix-video-active #videoOsdPage,
|
||||
body.arrflix-video-active .libraryPage:has(.htmlvideoplayer) {
|
||||
background: transparent !important;
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention.** Any new bg-color rule on layer 0–4 ancestors MUST scope `:not(.arrflix-video-active)`. Add `darkPct` assertion to `bin/headless-test-v2.py` (TODO doc 30/31). Ref docs/26 INC7-final, docs/28 INC7-final, docs/31 layer model.
|
||||
|
||||
## ERROR 2 — Video covers OSD controls (z-index hack)
|
||||
|
||||
**Symptom.** Frames visible, OSD scrubber/buttons clipped or unclickable.
|
||||
|
||||
**Root cause.** Forced `<video> { z-index: 9999 }` to "lift" above an unknown overlay. Stock OSD sits at z 1100–1500; lifting `<video>` buries the controls.
|
||||
|
||||
**Diagnostic.** DevTools → click where scrubber should be → if click target is `<video>`, z-index is wrong.
|
||||
|
||||
**Fix.** Revert. Delete the override. Real bug is always opaque ancestors (Error 1). Commit `d4ddf6f` reverted.
|
||||
|
||||
**Prevention.** Stock Jellyfin owns z 1000–2000. Never override. Docs/31: *"If you think you need to z-index `<video>` higher: you don't."*
|
||||
|
||||
## ERROR 3 — Bind-mount inode swap
|
||||
|
||||
**Symptom.** Host file md5 changed after `cp`/`scp`, but `docker exec md5sum` returns old hash.
|
||||
|
||||
**Root cause.** Single-file Docker bind mount tracks inode at container start, not path. `cp src dest` (or scp) creates a NEW inode; container keeps the old one. Docs/31: *"bind-mount inode swap doesn't refresh container view."*
|
||||
|
||||
**Diagnostic.**
|
||||
```bash
|
||||
ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html'
|
||||
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
|
||||
# Differ → inode swap
|
||||
```
|
||||
|
||||
**Fix.** `docker restart jellyfin` (or `jellyfin-dev`) after every `cp`/`scp` of a single-file bind.
|
||||
|
||||
**Prevention.** Deploy: scp → restart → curl-verify md5. Ref docs/26 §A, docs/31 DO-NOT-DO.
|
||||
|
||||
## ERROR 4 — branding.xml XML parse silent fail
|
||||
|
||||
**Symptom.** Theme partially loads. `curl /Branding/Css.css` returns HTTP 200 with **0 bytes**. No log, no banner. Doc 30: *"Silent XML parse failures with zero UI feedback are the worst class of bug."*
|
||||
|
||||
**Root cause.** Unescaped `<` in `<CustomCss>`. CSS comment with `<video>` literal makes XML parser treat it as a child element. Branding loader catches the exception, serves empty CSS.
|
||||
|
||||
**Diagnostic.**
|
||||
```bash
|
||||
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:ro alpine sh -c \
|
||||
"apk add --no-cache libxml2-utils >/dev/null 2>&1 && xmllint --noout /d/branding.xml"
|
||||
curl -s https://arrflix.s8n.ru/Branding/Css.css | wc -c # expect ~36000
|
||||
```
|
||||
|
||||
**Fix.** Escape: `<video>` → `<video>` for any `<tag>` literal in CSS comments.
|
||||
|
||||
**Prevention.** Add `xmllint --noout branding.xml` to CI gate (TODO doc 30/31). Smoke-test `/Branding/Css.css` byte count.
|
||||
|
||||
## ERROR 5 — test/123 password nuked after docker cp
|
||||
|
||||
**Symptom.** Dev login rejects `test/123` with 401, no password change requested.
|
||||
|
||||
**Root cause.** `docker cp` (or in-container cp) on `jellyfin.db` either replaced it with an older copy or triggered SQLite Error 8 readonly that rolled back the password write. Userns-remap leftovers (uid 101000) loop this.
|
||||
|
||||
**Diagnostic.**
|
||||
```bash
|
||||
ssh user@nullstone 'ls -ln /home/docker/jellyfin-dev/config/data/jellyfin.db' # uid 1000
|
||||
docker logs jellyfin-dev 2>&1 | grep -iE "readonly|sqlite.*error 8"
|
||||
```
|
||||
|
||||
**Fix.** Stop container; sqlite3 `UPDATE Users SET Password=NULL WHERE Username='test'`; restart; API-set password (doc 28 "Headless comparison" recipe).
|
||||
|
||||
**Prevention.** Never `docker cp` a live SQLite file. Stop first. `chown 1000:1000` after any cp into config volume.
|
||||
|
||||
## ERROR 6 — DB readonly after docker cp (uid 101000)
|
||||
|
||||
**Symptom.** POST `/Users/{id}/Configuration` returns 204 but GET shows field unchanged.
|
||||
|
||||
**Root cause.** `jellyfin.db` owned by uid 101000 (Docker userns subuid leftover); container runs as 1000. SQLite throws Error 8 readonly; API returns 204 anyway. Doc 26 §B: *"EVERY user-config save silently fails (HTTP 204 success, value not persisted)."*
|
||||
|
||||
**Diagnostic.**
|
||||
```bash
|
||||
ls -ln /home/docker/jellyfin/config/data/jellyfin.db # uid must be 1000
|
||||
docker logs jellyfin 2>&1 | grep -i "readonly\|error 8"
|
||||
```
|
||||
|
||||
**Fix.** `sudo chown -R 1000:1000 /home/docker/jellyfin/config /home/docker/jellyfin/cache && docker restart jellyfin`.
|
||||
|
||||
**Prevention.** Never trust 204 — always GET-verify (doc 26 forbidden #4, post-mortem #3). Init-container chowning to 1000:1000 on boot.
|
||||
|
||||
## ERROR 7 — camelCase class name typo
|
||||
|
||||
**Symptom.** `:has(.htmlVideoPlayer)` (camelCase V) never matches. Body stays opaque → black screen (Error 1).
|
||||
|
||||
**Root cause.** Jellyfin's class is **lowercase** `.htmlvideoplayer`. Commit `a6cf925` shipped the typo. Docs/31: *"There is no `.htmlVideoPlayer` (camelCase). Don't confuse them."*
|
||||
|
||||
**Diagnostic.** DevTools → search selector. "0 results" while video plays = wrong casing.
|
||||
|
||||
**Fix.** Use `.htmlvideoplayer` lowercase OR rely on `body.arrflix-video-active` toggled by JS (preferred — class-on-body robust to DOM changes).
|
||||
|
||||
**Prevention.** Read docs/31 layer model BEFORE writing `:has()`. Inspect live DOM, never guess casing.
|
||||
|
||||
## ERROR 8 — Wrong Jellyfin class assumption
|
||||
|
||||
**Symptom.** CSS rule appears correct but does nothing. e.g. `body.itemDetailPage { ... }` — body's actual class is `libraryDocument` in 10.10.3.
|
||||
|
||||
**Root cause.** Jellyfin web class names aren't stable. Doc 26 post-mortem #4: *"Body class on detail pages is `libraryDocument`, not `itemDetailPage`. Use `.itemDetailPage` directly or `:has(.itemDetailPage)`."* Same trap with `.skinHeader` (z:1) vs `.videoPlayerContainer` (z:1000).
|
||||
|
||||
**Diagnostic.** `document.body.className`; `document.querySelectorAll('.itemDetailPage').length`.
|
||||
|
||||
**Fix.** Use `:has()` on ancestors: `.layout-desktop:has(.itemDetailPage) { ... }`.
|
||||
|
||||
**Prevention.** Inspect live DOM in 10.10.3 — never trust forum/older-doc selectors. Ref doc 26 post-mortem #4, doc 31 layer model.
|
||||
|
||||
## ERROR 9 — HDR10 grey wash (tonemap off)
|
||||
|
||||
**Symptom.** HDR10 source (4K Rick & Morty) renders desaturated grey. NOT pure black — distinct from Error 1.
|
||||
|
||||
**Root cause.** `EnableTonemapping=false` while serving HDR10 (`smpte2084` / `bt2020nc` / `yuv420p10le`). ffmpeg passes HDR pixels to SDR transcode without zscale→tonemap→format → wrong colorspace → grey wash. Doc 21 traced for R&M Pilot.
|
||||
|
||||
**Diagnostic.** `grep -E "EnableTonemapping|TonemappingAlgorithm" /home/docker/jellyfin/config/config/encoding.xml` — expect `true` + `bt2390`.
|
||||
|
||||
**Fix.** `sed -i s|<EnableTonemapping>false|<EnableTonemapping>true|` then `docker restart jellyfin`. Or Dashboard → Playback → Tone Mapping. Commit `1168ba6`.
|
||||
|
||||
**Prevention.** Tonemap ON for any library with HDR10 sources. Don't toggle off as "perf fix" — grey wash is worse than slow encode.
|
||||
|
||||
## ERROR 10 — Favicon clobbered by lockFavicon shim
|
||||
|
||||
**Symptom.** Browser tab shows stock Jellyfin purple-swirl despite ARRFLIX overlay shipping A-mark icon link.
|
||||
|
||||
**Root cause.** Jellyfin's `lockFavicon()` runs on `setInterval`, re-pinning its `<link rel="icon">` and overwriting our overlay's link. Two shims race; Jellyfin wins.
|
||||
|
||||
**Diagnostic.** `[...document.querySelectorAll('link[rel*=icon]')].map(l => l.href)` — our `data-arrflix-icon="A"` element gone or href swapped.
|
||||
|
||||
**Fix.** ARRFLIX shim stamps `data-arrflix-icon="A"` and runs its own re-pin loop on the same interval. Removes stock wordmark links every tick. Commit `1168ba6`.
|
||||
|
||||
**Prevention.** Never one-shot DOM writes for elements Jellyfin actively manages (favicon, body class, drawer). Use observe + reapply, or class-on-body.
|
||||
|
||||
## ERROR 11 — Backdrop residue / carousel black band
|
||||
|
||||
**Symptom.** Backdrop missing on detail-page mid-scroll → black band behind "More from Season N". Or previous item's blurhash sticks after navigation.
|
||||
|
||||
**Root cause.** `.backdropContainer` defaults to non-fixed positioning — scrolls out of view (INC2). Sections below paint against body's `#000`. Separately, opaque `.emby-scroller { background:#000 !important }` (originally for home grey strips) leaks into detail-page carousel wrappers (INC4).
|
||||
|
||||
**Diagnostic.** DOM-walk every `.scrollSlider` in `.itemDetailPage`, log ancestors with non-transparent computed bg. Locator pattern in doc 26 §INC4.
|
||||
|
||||
**Fix.** Pin backdrop `position:fixed; top:0; height:100vh; z-index:0` (INC2). Transparent-scope `.itemDetailPage` wrappers: `.emby-scroller`, `.scrollSliderContainer`, `.detailVerticalSection*`, `.padded-bottom-page`, `.itemsContainer` (INC3+INC4).
|
||||
|
||||
**Prevention.** Any new `background:#000 !important` MUST be scoped from day one — never bare `.emby-scroller` (INC4 lesson). Headless test takes top + scrolled (50%) screenshots.
|
||||
|
||||
## ERROR 12 — Quick Connect bypass + login mismatch
|
||||
|
||||
**Symptom.** Login shows Quick Connect button + user-picker tiles instead of curated ARRFLIX manual-login.
|
||||
|
||||
**Root cause.** `system.xml` has `QuickConnectAvailable=true` AND non-admin `IsHidden=false` so picker enumerates. Theme expects QC off and all non-admin hidden.
|
||||
|
||||
**Diagnostic.**
|
||||
```bash
|
||||
grep QuickConnectAvailable /home/docker/jellyfin/config/config/system.xml
|
||||
docker exec jellyfin sqlite3 /config/data/jellyfin.db "SELECT Username,IsHidden FROM Users"
|
||||
```
|
||||
|
||||
**Fix.** `QuickConnectAvailable=false` in `system.xml`, restart. `UPDATE Users SET IsHidden=1 WHERE Username != 's8n'`.
|
||||
|
||||
**Prevention.** Closed in v6-stable (doc 30). Headless test asserts no `.btnQuickConnect` and no `.cardBox-login` tiles on login.
|
||||
|
||||
## Pattern recognition cheat sheet
|
||||
|
||||
| If you see... | Likely # |
|
||||
|---|---|
|
||||
| black video, audio plays, element decoding | 1 |
|
||||
| video clipped, OSD controls hidden | 2 |
|
||||
| local file changed, live page unchanged | 3 |
|
||||
| empty `/Branding/Css.css` | 4 |
|
||||
| test/123 401 / sqlite readonly logs | 5+6 |
|
||||
| selector typo, "rule not applied" | 7+8 |
|
||||
| HDR content washed-out grey | 9 |
|
||||
| wrong logo in browser tab | 10 |
|
||||
| black band behind carousel | 11 |
|
||||
| Quick Connect / user picker on login | 12 |
|
||||
|
||||
## When to add a new error
|
||||
|
||||
After ANY incident:
|
||||
1. Add to index table with date + recurrence count.
|
||||
2. Add full Symptom / Root cause / Diagnostic / Fix / Prevention.
|
||||
3. Update cheat sheet if symptom phrasing is novel.
|
||||
4. If recurrence ≥ 3: add CI gate to `testing/SMOKE-TEST.md`.
|
||||
209
testing/HEADLESS-PROBE.md
Normal file
209
testing/HEADLESS-PROBE.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# HEADLESS-PROBE — playwright + DOM recipes
|
||||
|
||||
> Copy-paste these to verify any theme/playback change. All use `mcr.microsoft.com/playwright/python:v1.49.0-noble` with `--userns=host --network container:jellyfin-dev` (or `jellyfin` for prod).
|
||||
|
||||
## Setup (one-time per session)
|
||||
|
||||
```bash
|
||||
ssh user@192.168.0.100 'docker pull mcr.microsoft.com/playwright/python:v1.49.0-noble' >/dev/null
|
||||
mkdir -p /tmp/arrflix-probes
|
||||
# Run pattern (on nullstone):
|
||||
docker run --rm --userns=host --network container:jellyfin-dev \
|
||||
-v /tmp/arrflix-probes:/out -v /tmp/probe-X.py:/probe.py:ro \
|
||||
mcr.microsoft.com/playwright/python:v1.49.0-noble python /probe.py
|
||||
```
|
||||
|
||||
## RECIPE 1 — auth + pre-seed credentials
|
||||
|
||||
Boilerplate every recipe imports. `/Users/AuthenticateByName` returns `{AccessToken, User.Id, ServerId}`. Jellyfin web reads `localStorage['jellyfin_credentials']` on boot — pre-seeding via `add_init_script` skips login.
|
||||
|
||||
```python
|
||||
import asyncio, json, urllib.request
|
||||
from playwright.async_api import async_playwright
|
||||
URL='http://127.0.0.1:8096'
|
||||
USER,PW='test','123'
|
||||
def auth():
|
||||
req=urllib.request.Request(f"{URL}/Users/AuthenticateByName",
|
||||
data=json.dumps({"Username":USER,"Pw":PW}).encode(),
|
||||
headers={"Content-Type":"application/json","Authorization":'MediaBrowser Client="probe", Device="x", DeviceId="probe-1", Version="1.0"'},method="POST")
|
||||
return json.loads(urllib.request.urlopen(req,timeout=15).read())
|
||||
a=auth(); token=a["AccessToken"]; uid=a["User"]["Id"]; sid=a["ServerId"]
|
||||
```
|
||||
|
||||
Pre-seed creds via `add_init_script`:
|
||||
|
||||
```python
|
||||
await page.add_init_script(f"""
|
||||
localStorage.setItem('jellyfin_credentials', JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}));
|
||||
""")
|
||||
```
|
||||
|
||||
If auth returns 401 + `sqlite-readonly` in `docker logs jellyfin-dev`, test password got nuked. Recovery: `docker exec jellyfin-dev sqlite3 /config/data/jellyfin.db "UPDATE Users SET Password=NULL,EasyPassword=NULL WHERE Username='test'"` then `docker restart jellyfin-dev` and POST `/Users/{uid}/Password` with `{NewPw:"123"}`.
|
||||
|
||||
## RECIPE 2 — bg-color of every ancestor of `<video>`
|
||||
|
||||
Tests L1/L2 transparent rules. Run during playback.
|
||||
|
||||
```js
|
||||
() => {
|
||||
const v = document.querySelector('video.htmlvideoplayer'); if (!v) return {found:false};
|
||||
const chain=[];
|
||||
for (let el=v; el; el=el.parentElement) {
|
||||
chain.push({tag:el.tagName, cls:String(el.className).slice(0,80), id:el.id, bg:getComputedStyle(el).backgroundColor, z:getComputedStyle(el).zIndex});
|
||||
}
|
||||
return {found:true, chain};
|
||||
}
|
||||
```
|
||||
|
||||
Expect every ancestor `rgba(0,0,0,0)` except `<html>` = `rgb(0,0,0)`.
|
||||
|
||||
## RECIPE 3 — darkPct on rendered viewport
|
||||
|
||||
Detects "video decodes but is visually black" (doc 28 INC7).
|
||||
|
||||
```js
|
||||
() => {
|
||||
const v = document.querySelector('video.htmlvideoplayer'); if (!v) return null;
|
||||
const c = document.createElement('canvas'); c.width=320; c.height=180;
|
||||
const ctx = c.getContext('2d'); ctx.drawImage(v, 0, 0, 320, 180);
|
||||
const data = ctx.getImageData(0, 0, 320, 180).data;
|
||||
let dark=0, total=320*180;
|
||||
for (let i=0; i<data.length; i+=4) {
|
||||
const max = Math.max(data[i], data[i+1], data[i+2]);
|
||||
if (max < 32) dark++;
|
||||
}
|
||||
return {darkPct: dark/total, currentTime:v.currentTime, videoWidth:v.videoWidth};
|
||||
}
|
||||
```
|
||||
|
||||
Expect `darkPct < 0.2` during playback. `> 0.7` = black overlay.
|
||||
|
||||
## RECIPE 4 — md5 chain (host → container → served)
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'md5sum /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
|
||||
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
|
||||
ssh user@nullstone 'docker exec jellyfin-dev curl -s http://127.0.0.1:8096/web/index.html | md5sum'
|
||||
```
|
||||
|
||||
All three must match. If host ≠ container: bind-mount inode swap (ERROR-PATTERNS#3) — `docker restart jellyfin-dev`.
|
||||
|
||||
## RECIPE 5 — computed style of selector
|
||||
|
||||
```js
|
||||
() => {
|
||||
const el = document.querySelector('TARGET-SELECTOR-HERE');
|
||||
if (!el) return {found:false};
|
||||
const cs = getComputedStyle(el);
|
||||
const props = ['backgroundColor', 'color', 'outline', 'border', 'zIndex', 'display', 'visibility'];
|
||||
return {found:true, computed:Object.fromEntries(props.map(p=>[p, cs[p]]))};
|
||||
}
|
||||
```
|
||||
|
||||
## RECIPE 6 — dump CSS rules matching selector token (cascade debug)
|
||||
|
||||
```js
|
||||
(token) => {
|
||||
const rules=[];
|
||||
for (const s of document.styleSheets) {
|
||||
try { for (const r of s.cssRules) {
|
||||
if (r.style && r.selectorText && r.selectorText.indexOf(token)>=0)
|
||||
rules.push({sel:r.selectorText.slice(0,180), css:r.style.cssText.slice(0,200), src:(s.href||'inline').slice(-80)});
|
||||
}} catch(e){ rules.push({err:String(e).slice(0,40), src:s.href}); }
|
||||
}
|
||||
return rules; // later sheets override earlier; arrflix overrides should appear last
|
||||
}
|
||||
// page.evaluate("(t)=>{...}", "htmlVideoPlayer")
|
||||
```
|
||||
|
||||
## RECIPE 7 — open dropdown, sample selected listItem
|
||||
|
||||
Audio/subtitle picker theme check.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
const btn = document.querySelector('.btnAudio, .audioMenuButton'); btn?.click();
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
const sel = document.querySelector('.actionSheet .listItem.selected, .actionSheet .listItem-button.selected, .actionSheet .listItem.focused');
|
||||
if (!sel) return {found:false};
|
||||
const cs = getComputedStyle(sel);
|
||||
return {found:true, computed:{outline:cs.outline, bg:cs.backgroundColor, color:cs.color}};
|
||||
}
|
||||
```
|
||||
|
||||
## RECIPE 8 — full playback smoke (auth + play + sample @5/10/15s)
|
||||
|
||||
Reuses Recipe 1 boilerplate. Save as `testing/recipes/smoke-playback.py`:
|
||||
|
||||
```python
|
||||
# (prelude from Recipe 1: auth(), token/uid/sid)
|
||||
ITEM='324f75b84f394a5d9b0749c0679f23b9'
|
||||
SAMPLE = """()=>{const v=document.querySelector('video.htmlvideoplayer');
|
||||
if(!v)return{hasVideo:false};
|
||||
const c=document.createElement('canvas');c.width=320;c.height=180;
|
||||
const x=c.getContext('2d');x.drawImage(v,0,0,320,180);
|
||||
const d=x.getImageData(0,0,320,180).data;let dark=0;
|
||||
for(let i=0;i<d.length;i+=4)if(Math.max(d[i],d[i+1],d[i+2])<32)dark++;
|
||||
return{hasVideo:true,t:v.currentTime,w:v.videoWidth,darkPct:dark/57600};}"""
|
||||
async def main():
|
||||
async with async_playwright() as p:
|
||||
b=await p.chromium.launch(headless=True,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
|
||||
page=await (await b.new_context(viewport={"width":1280,"height":720})).new_page()
|
||||
await page.add_init_script(f"localStorage.setItem('jellyfin_credentials',JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}))")
|
||||
await page.goto(f"{URL}/web/index.html#/details?id={ITEM}",wait_until="networkidle",timeout=30000)
|
||||
await page.wait_for_timeout(4000)
|
||||
try: await page.click('button.btnPlay,.mainDetailButtons .btnPlay,button[title="Play"]',timeout=5000)
|
||||
except: pass
|
||||
out=[]
|
||||
for t in (5,10,15):
|
||||
await page.wait_for_timeout(5000)
|
||||
out.append({"at_s":t, **(await page.evaluate(SAMPLE))})
|
||||
await page.screenshot(path="/out/smoke.png"); print("SMOKE",json.dumps(out,indent=1))
|
||||
await b.close()
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Pass: all three samples `hasVideo:true`, `t` advancing, `darkPct < 0.2`.
|
||||
|
||||
## RECIPE 9 — compare two overlays (visual diff)
|
||||
|
||||
```bash
|
||||
# Nullstone: swap+render each, scp screenshots back.
|
||||
for V in baseline candidate; do
|
||||
ssh user@nullstone "docker cp /tmp/$V-index.html jellyfin-dev:/jellyfin/jellyfin-web/index.html && docker restart jellyfin-dev"; sleep 8
|
||||
ssh user@nullstone "docker run --rm --userns=host --network container:jellyfin-dev -v /tmp/probe-real.py:/p.py:ro -v /tmp/arrflix-probes:/out mcr.microsoft.com/playwright/python:v1.49.0-noble python /p.py"
|
||||
scp user@nullstone:/tmp/arrflix-probes/dev-real-vid.png /tmp/$V.png
|
||||
done
|
||||
# Onyx: Pillow diff
|
||||
python -c "from PIL import Image,ImageChops as C
|
||||
a=Image.open('/tmp/baseline.png').convert('RGB');b=Image.open('/tmp/candidate.png').convert('RGB')
|
||||
d=C.difference(a,b);h=d.histogram();print(f'changed={sum(h[1:256])+sum(h[257:512])+sum(h[513:768])} bbox={d.getbbox()}');d.save('/tmp/overlay-diff.png')"
|
||||
```
|
||||
|
||||
## RECIPE 10 — favicon shim (lockFavicon) verify
|
||||
|
||||
```js
|
||||
() => Array.from(document.querySelectorAll('link[rel*="icon"]')).map(l => ({
|
||||
rel:l.rel, sizes:l.sizes?.value, dataAttr:l.getAttribute('data-arrflix-icon'),
|
||||
isArrflixA:l.href.indexOf('iVBORw0KGgoAAAANSUhEUgAAAIo')>0,
|
||||
hrefHead:l.href.slice(0,80)
|
||||
}))
|
||||
```
|
||||
|
||||
Expect `dataAttr:"1"` + arrflix base64 prefix on every icon link after ~5s (poll interval).
|
||||
|
||||
## RECIPE 11 — force `arrflix-video-active` (theme isolation, no real playback)
|
||||
|
||||
```js
|
||||
() => {
|
||||
const v=document.createElement('div'); v.className='htmlVideoPlayer';
|
||||
v.style.cssText='position:fixed;inset:0;z-index:5;'; document.body.appendChild(v);
|
||||
document.body.classList.add('arrflix-video-active');
|
||||
const els=['body','#reactRoot','.skinBody','.backgroundContainer','.mainAnimatedPages','.pageContainer','.videoPlayerContainer','.htmlVideoPlayer'];
|
||||
return Object.fromEntries(els.map(s=>{const el=document.querySelector(s); return [s, el?{bg:getComputedStyle(el).backgroundColor,z:getComputedStyle(el).zIndex}:null];}));
|
||||
}
|
||||
```
|
||||
|
||||
## Where to store probe scripts
|
||||
|
||||
`testing/snipUSER-Es/` — one-liners + bash. `testing/recipes/` — full python (recipes 1, 8, 9 live here).
|
||||
32
testing/README.md
Normal file
32
testing/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# testing/
|
||||
|
||||
Manual + automated verification + recovery for the ARRFLIX overlay (web-overrides/index.html) + branding.xml + system.xml. Read this folder before any theme edit, deployment, or recovery.
|
||||
|
||||
## Index
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| THEMING.md | Safe-edit checklist + layer model + specificity rules (links to docs/31) |
|
||||
| ERROR-PATTERNS.md | Catalog of every theme/deploy error so far + fixes |
|
||||
| HEADLESS-PROBE.md | Playwright + DOM-probe recipes for verifying changes |
|
||||
| ROLLBACK.md | Emergency revert procedures (overlay, branding, full prod) |
|
||||
| SMOKE-TEST.md | Manual 4-step verify checklist before/after deploy |
|
||||
| DEPLOY.md | Dev → prod promotion workflow (overlay swap + restart + verify) |
|
||||
| snipUSER-Es/ | Reusable bash, sqlite, playwright, ssh snipUSER-Es |
|
||||
| recipes/ | Step-by-step recipes for common tasks (apply theme variant, fix pw, etc) |
|
||||
| incidents/ | Post-mortems for past + future bugs (referenced from docs/26, 28, 30, 31) |
|
||||
|
||||
## Quickstart for "I want to edit the theme"
|
||||
|
||||
1. Read `THEMING.md` — pay attention to the layer model + L1/L2 paired rules
|
||||
2. Edit `bin/inject-middle-theme.py`. Keep it dev-only, scoped under `body.arrflix-themed`
|
||||
3. `python3 bin/inject-middle-theme.py` to regenerate `web-overrides/index.html`
|
||||
4. scp the regenerated file to dev's overlay path (NOT prod)
|
||||
5. `docker restart jellyfin-dev` — bind-mount inode swap requires restart
|
||||
6. Run `SMOKE-TEST.md` checklist on dev
|
||||
7. Hard-refresh the browser (Ctrl+Shift+R) — defeats Service Worker + HTTP cache
|
||||
8. If green: promote per `DEPLOY.md`. If red: revert per `ROLLBACK.md`
|
||||
|
||||
## Why this exists
|
||||
|
||||
Five+ regressions in 24 hours during 2026-05-09 (docs/26 INC1-5, docs/28 INC7, docs/30 v6-stable). Each one was the same anti-pattern: an opaque CSS rule painted over the video. Combined with bind-mount inode + browser-cache + XML-parse-silent-failures + Cineplex CSS shadow specificity → debugging nightmare. This folder is the institutional memory.
|
||||
154
testing/ROLLBACK.md
Normal file
154
testing/ROLLBACK.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# ROLLBACK — emergency revert procedures
|
||||
|
||||
> When something breaks, follow these recipes. Each is one shell block + verify step.
|
||||
|
||||
## When to use this
|
||||
|
||||
Any of:
|
||||
- Live users report black screens, missing UI, can't login
|
||||
- Headless probe shows `darkPct > 50%` during playback
|
||||
- `/Branding/Css.css` returns 0 bytes
|
||||
- Container won't start or stays unhealthy
|
||||
- `curl -s https://arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN` returns 0
|
||||
- Owner says "rollback" — don't debate, restore first, diagnose after
|
||||
|
||||
## ROLLBACK 1 — overlay (most common)
|
||||
|
||||
Symptom: theme regression, wrong colors, missing features after a deploy. Login or home page renders but looks wrong.
|
||||
|
||||
Source: every prod deploy creates a `.bak.pre-<reason>.<unix-ts>` file in `/opt/docker/jellyfin/web-overrides/`. Pick the most recent (or the one matching the last-known-good state — e.g. `index.html.bak.pre-favfix.1778318089` is pre-v6+favfix).
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'set -e
|
||||
TS=$(ls /opt/docker/jellyfin/web-overrides/index.html.bak.* 2>/dev/null | sort -V | tail -1)
|
||||
echo "Restoring from: $TS"
|
||||
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine sh -c "cp $TS /d/index.html && chown root:root /d/index.html && md5sum /d/index.html"
|
||||
docker restart jellyfin && sleep 12
|
||||
docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
|
||||
```
|
||||
|
||||
Verify: `curl -s https://arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN` returns `1`. Then hard-refresh the browser (`Ctrl+Shift+R`) — defeats Service Worker + HTTP cache.
|
||||
|
||||
## ROLLBACK 2 — branding.xml
|
||||
|
||||
Symptom: `/Branding/Css.css` returns 0 bytes (Cineplex theme stops loading site-wide). Usually caused by an unescaped `<video>` literal or other XML parse failure inside `<CustomCss>` (silent failure — HTTP 200 empty body, no admin alert).
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'set -e
|
||||
TS=$(ls /home/docker/jellyfin/config/config/branding.xml.bak.* 2>/dev/null | sort -V | tail -1)
|
||||
echo "Restoring from: $TS"
|
||||
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine sh -c "cp $TS /d/branding.xml"
|
||||
docker restart jellyfin && sleep 12
|
||||
docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c'
|
||||
```
|
||||
|
||||
Expect: `> 30000` bytes (v6-stable serves 36 256 B). 0 bytes = still broken — the backup itself was corrupt; pick an older `.bak.*` and retry.
|
||||
|
||||
## ROLLBACK 3 — encoding.xml (HDR / tonemap)
|
||||
|
||||
Symptom: HDR10 playback looks washed out / grey, or transcode fails after flipping `EnableTonemapping`. Backup created pre-flip as `encoding.xml.bak.pre-tonemap.1778318089`.
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'set -e
|
||||
TS=$(ls /home/docker/jellyfin/config/config/encoding.xml.bak.* 2>/dev/null | sort -V | tail -1)
|
||||
echo "Restoring from: $TS"
|
||||
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine sh -c "cp $TS /d/encoding.xml"
|
||||
docker restart jellyfin && sleep 12
|
||||
docker exec jellyfin grep -E "EnableTonemapping|TonemappingAlgorithm|HardwareAccelerationType" /config/config/encoding.xml'
|
||||
```
|
||||
|
||||
Verify: values match the last-known-good (v6-stable: `EnableTonemapping=true`, `TonemappingAlgorithm=bt2390`, `HardwareAccelerationType=none`). Stop any in-flight HDR10 transcode and re-start it from the client.
|
||||
|
||||
## ROLLBACK 4 — full prod = exact state from repo HEAD
|
||||
|
||||
When you don't trust the live state (drift, tampering, multiple bad deploys), force prod to match `git origin/main`:
|
||||
|
||||
```bash
|
||||
cd /tmp/arrflix-recon
|
||||
git fetch origin && git checkout origin/main
|
||||
md5sum web-overrides/index.html
|
||||
scp web-overrides/index.html user@nullstone:/tmp/repo-overlay.html
|
||||
ssh user@nullstone 'set -e
|
||||
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw -v /tmp:/src:ro alpine sh -c "cp /src/repo-overlay.html /d/index.html && chown root:root /d/index.html && md5sum /d/index.html"
|
||||
docker restart jellyfin && sleep 12
|
||||
docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
|
||||
```
|
||||
|
||||
Verify: container md5 == repo md5 (v6-stable: `364cc890c58f02d07cf50b43b31a48f0`). `curl -s https://arrflix.s8n.ru/web/index.html | md5sum` should match too.
|
||||
|
||||
## ROLLBACK 5 — dev = exact clone of prod
|
||||
|
||||
When dev has drifted and you want to reset it to live prod state (for a clean theme test sandbox):
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'set -e
|
||||
docker run --rm --userns=host \
|
||||
-v /opt/docker/jellyfin/web-overrides:/p:ro \
|
||||
-v /opt/docker/jellyfin-dev/web-overrides:/d:rw \
|
||||
alpine sh -c "cp /p/index.html /d/index-dev.html && chown 1000:1000 /d/index-dev.html && md5sum /p/index.html /d/index-dev.html"
|
||||
docker restart jellyfin-dev && sleep 12
|
||||
docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
|
||||
```
|
||||
|
||||
Note dev's overlay filename is `index-dev.html` (NOT `index.html`) and ownership is `user:user` (1000:1000), unlike prod's `root:root`. Also resync `branding.xml` if needed: source `/home/docker/jellyfin/config/config/branding.xml` → dest `/home/docker/jellyfin-dev/config/config/branding.xml` (backup as `branding.xml.bak.dev-pre-resync` first).
|
||||
|
||||
## ROLLBACK 6 — git revert last commit
|
||||
|
||||
When the bad change is in repo HEAD and you want it gone from history (then redeploy cleanly):
|
||||
|
||||
```bash
|
||||
cd /tmp/arrflix-recon
|
||||
git log --oneline -5
|
||||
git revert HEAD --no-edit
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Then redeploy via ROLLBACK 4 to push the reverted state to prod. If the bad commit is more than one back, use `git revert <sha>` for each, or `git revert <bad-sha>..HEAD --no-edit` for a range.
|
||||
|
||||
## ROLLBACK 7 — recover dev `test`/`123` password
|
||||
|
||||
Symptom: dev login broken, can't auth as `test`/`123`. Standard sqlite password-reset cycle (dev only — never prod).
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'set -e
|
||||
docker stop jellyfin-dev
|
||||
DB=/home/docker/jellyfin-dev/config/data/jellyfin.db
|
||||
cp $DB ${DB}.bak.pre-pwreset.$(date +%s)
|
||||
docker run --rm -v /home/docker/jellyfin-dev/config/data:/d:rw alpine sh -c "apk add --no-cache sqlite >/dev/null && sqlite3 /d/jellyfin.db \"UPDATE Users SET Password=NULL, EasyPassword=NULL WHERE Username=\x27test\x27;\""
|
||||
chown -R 1000:1000 /home/docker/jellyfin-dev/config/data
|
||||
docker start jellyfin-dev && sleep 12'
|
||||
```
|
||||
|
||||
Then in browser: log in as `test` with **blank** password → admin → user `test` → set password to `123`. Verify `curl -X POST https://dev.arrflix.s8n.ru/Users/AuthenticateByName -H 'Content-Type: application/json' -d '{"Username":"test","Pw":"123"}'` returns a token.
|
||||
|
||||
## ROLLBACK 8 — bind-mount inode swap (just restart)
|
||||
|
||||
Symptom: you changed an overlay file and the served bytes don't match the file on disk. `docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html` differs from `md5sum /opt/docker/jellyfin/web-overrides/index.html`. Bind-mount captured the old inode; container is serving stale.
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'docker restart jellyfin && sleep 12 && docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
|
||||
# or for dev:
|
||||
ssh user@nullstone 'docker restart jellyfin-dev && sleep 12 && docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
|
||||
```
|
||||
|
||||
This is not a "rollback" per se — it's the cure for any `cp`-without-restart that left the container out of sync.
|
||||
|
||||
## Backups directory layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/opt/docker/jellyfin/web-overrides/index.html.bak.*` | prod overlay (root:root) |
|
||||
| `/opt/docker/jellyfin-dev/web-overrides/index-dev.html.bak.*` | dev overlay (user:user, note `-dev` suffix) |
|
||||
| `/home/docker/jellyfin/config/config/branding.xml.bak.*` | prod branding |
|
||||
| `/home/docker/jellyfin/config/config/encoding.xml.bak.*` | prod encoding |
|
||||
| `/home/docker/jellyfin-dev/config/config/branding.xml.bak.*` | dev branding |
|
||||
| `/home/docker/jellyfin-dev/config/data/jellyfin.db.bak.*` | dev sqlite (user db) |
|
||||
|
||||
Keep the **most recent** `.bak` per file; older ones can be deleted (per doc 30 cleanup). Never delete a `.bak.*` you haven't verified is older than the current good state.
|
||||
|
||||
## After any rollback
|
||||
|
||||
1. **Notify users** — restart drops in-flight stream sessions; if anyone was mid-playback they'll get bumped.
|
||||
2. **Open an incident** in `testing/incidents/` (post-mortem) — what broke, what backup was used, container md5 before/after, owner-visible impact.
|
||||
3. **Add the failure mode to `testing/ERROR-PATTERNS.md`** if novel.
|
||||
4. **Verify v6-stable invariants** — overlay md5 prod==dev, `/Branding/Css.css` > 30 000 B, `EnableTonemapping=true`, login + playback both green via `testing/SMOKE-TEST.md`.
|
||||
74
testing/SMOKE-TEST.md
Normal file
74
testing/SMOKE-TEST.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# SMOKE-TEST — pre/post-deploy verify checklist
|
||||
|
||||
> Run this on dev BEFORE every prod promotion. Run again on prod AFTER deploy.
|
||||
|
||||
## Manual (5 min, browser)
|
||||
|
||||
1. **Login pristine** — open dev.arrflix.s8n.ru in a fresh incognito window. Hard-refresh.
|
||||
- [ ] ARRFLIX red logo top-left
|
||||
- [ ] No Movies/Series links visible (gated by auth)
|
||||
- [ ] User+Password fields, red Sign In button, "Welcome to ARRFLIX" footer
|
||||
- [ ] Background pure black, no #101010 stripe
|
||||
|
||||
2. **Login + home** — sign in as `test/123` (dev) or your account (prod). After login:
|
||||
- [ ] Wordmark logo dead-center in header
|
||||
- [ ] MOVIES + SERIES uppercase nav links left
|
||||
- [ ] 🔍 search icon right
|
||||
- [ ] No My Media row (.section0 hidden)
|
||||
- [ ] Continue Watching / Next Up / Recently Added rows render
|
||||
- [ ] No grey stripe at bottom of page when scrolled
|
||||
|
||||
3. **Movies / Series navigation** — click MOVIES.
|
||||
- [ ] MOVIES link gets red glow + bold (variant E active state)
|
||||
- [ ] No back arrow visible on header
|
||||
- [ ] No duplicate "Movies" h3 title
|
||||
- [ ] Library renders or shows spinner (Jellyfin viewContainer)
|
||||
|
||||
4. **Search** — click 🔍 icon.
|
||||
- [ ] Search input has red bottom underline on focus (NOT cyan ring)
|
||||
- [ ] Suggestions list red text on black
|
||||
|
||||
5. **Playback** — click any movie's Play button. Wait 10 seconds.
|
||||
- [ ] Video frame visible (not black, not white, not grey washed out)
|
||||
- [ ] OSD scrubber + play/pause buttons + settings icon click-able above video
|
||||
- [ ] Header bar HIDDEN during playback
|
||||
- [ ] Letterbox bars (top/bottom of video) are BLACK not white
|
||||
- [ ] Seek with scrubber works
|
||||
- [ ] Click settings → audio/subtitle dropdowns show red hairline ring on selected
|
||||
|
||||
6. **Browser tab favicon** — check tab.
|
||||
- [ ] Red ARRFLIX "A" mark (not Jellyfin triangle, not wordmark)
|
||||
|
||||
## Headless (1 min, automated)
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'docker run --rm --userns=host --network container:jellyfin-dev mcr.microsoft.com/playwright/python:v1.49.0-noble bash -c "pip install --quiet playwright==1.49.0 && python /tmp/smoke.py"'
|
||||
```
|
||||
|
||||
(`/tmp/smoke.py` lives in testing/recipes/smoke-headless.py — TODO)
|
||||
|
||||
Expected diag output:
|
||||
```
|
||||
{
|
||||
"loginRedSignIn": true,
|
||||
"wordmarkCenter": true,
|
||||
"myMediaHidden": true,
|
||||
"darkPctOnVideoFrame": 0.10,
|
||||
"osdControlsClickable": true,
|
||||
"letterboxBlack": true,
|
||||
"favIconAMark": true,
|
||||
"selectorOutlineRed": true
|
||||
}
|
||||
```
|
||||
|
||||
If ANY false: rollback (testing/ROLLBACK.md) and check testing/ERROR-PATTERNS.md for the matching pattern.
|
||||
|
||||
## md5 chain check
|
||||
|
||||
```bash
|
||||
ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
|
||||
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
|
||||
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
|
||||
```
|
||||
|
||||
Expected on dev-only deploy: prod ≠ dev (intentional). Container view = host file (else inode swap, restart). Once prod-promoted: all 4 should match.
|
||||
123
testing/THEMING.md
Normal file
123
testing/THEMING.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# THEMING — how to edit the ARRFLIX theme without breaking it
|
||||
|
||||
> Short, actionable companion to `docs/31-theme-layer-model-and-edit-guide.md`.
|
||||
> Read 31 once for the why; come back here for the checklist every edit.
|
||||
|
||||
## TL;DR — checklist before EVERY theme edit
|
||||
|
||||
1. Read `docs/31-theme-layer-model-and-edit-guide.md` (canonical layer model).
|
||||
2. Decide: **am I painting an ancestor of `<video>`?** (see layer table below).
|
||||
3. If yes → scope with `body.arrflix-themed:not(.arrflix-video-active)` AND add the matching transparent rule under `body.arrflix-themed.arrflix-video-active`.
|
||||
4. Use the specificity table to predict what wins. `!important` does NOT promote specificity.
|
||||
5. Edit `bin/inject-middle-theme.py` — NEVER hot-patch the deployed overlay.
|
||||
6. `python3 bin/inject-middle-theme.py` → `scp web-overrides/index.html dev:/opt/docker/jellyfin-dev/web-overrides/` → `docker restart jellyfin-dev`.
|
||||
7. Hard-refresh browser (`Ctrl+Shift+R`) — bind-mount serves stale otherwise.
|
||||
8. Run `testing/SMOKE-TEST.md` (login, home, video, OSD).
|
||||
9. Green? Promote per `testing/DEPLOY.md`. Red? `testing/ROLLBACK.md`.
|
||||
|
||||
## The layer model (condensed)
|
||||
|
||||
| Layer | Element | bg ownership |
|
||||
|------:|---------|--------------|
|
||||
| 0 | `<html>` | `#000` (JS inline-style pinned via `setProperty(...,'important')`) |
|
||||
| 1 | `<body>` | L1 `#000` off-video / L2 `transparent` on-video |
|
||||
| 2 | `.backgroundContainer` / `.skinBody` / `#reactRoot` | follows L1/L2 |
|
||||
| 3 | `.mainAnimatedPages` / `.pageContainer` | follows L1/L2 |
|
||||
| 4 | `.skinHeader` | `#000` off-video, `display:none` on-video |
|
||||
| 5 | `.videoPlayerContainer` (z:1000) → `<video.htmlvideoplayer>` | transparent on-video |
|
||||
| 6 | `.osdControls` / `.videoOsdBottom` (z:~1100–1500) | DO NOT touch — Jellyfin owns |
|
||||
| 7 | `.dialogContainer` / `.actionSheet` (z:~2000+) | DO NOT touch — Jellyfin owns |
|
||||
|
||||
## Specificity quick reference
|
||||
|
||||
| Selector | (a,b,c) | When to use |
|
||||
|----------|---------|-------------|
|
||||
| `body` | (0,0,1) | almost never |
|
||||
| `body.arrflix-themed` | (0,1,1) | base theme rule, off/on video both |
|
||||
| `body.arrflix-themed:not(.arrflix-video-active)` | (0,2,1) | **L1**: off-video bg paint |
|
||||
| `body.arrflix-themed.arrflix-video-active` | (0,2,1) | **L2**: on-video transparent |
|
||||
| `body.arrflix-themed.arrflix-video-active #videoOsdPage` | (1,2,1) | beats Cineplex `#videoOsdPage .pageContainer` (1,1,0) |
|
||||
| `body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader` | (0,4,2) | beats Cineplex `display:flex` on header |
|
||||
|
||||
L1 and L2 tie on (0,2,1). **Source order decides** — L2 must come AFTER L1 in `inject-middle-theme.py`. Reordering reopens the black-screen bug.
|
||||
|
||||
## DO NOT DO
|
||||
|
||||
- Set `z-index` on `<video>` or `.videoPlayerContainer` above 1000 → covers OSD scrubber/buttons (image-12 incident).
|
||||
- Add `background-color` rules without `:not(.arrflix-video-active)` gate → black-screen-over-video.
|
||||
- Hot-patch `/opt/docker/jellyfin/web-overrides/index.html` in place → repo↔prod drift, INC1 root cause.
|
||||
- `cp` overlay then skip `docker restart jellyfin` → bind-mount inode swap, container serves stale.
|
||||
- Use `:has(.htmlVideoPlayer)` (camelCase) — class is `.htmlvideoplayer` lowercase. The selector silently never matches.
|
||||
- Drop a `<video>` literal into `branding.xml` `<CustomCss>` (even in a comment) without escaping → XML parse fails silently, theme disappears site-wide. Use `<video>`.
|
||||
- Add `!important` hoping it beats a higher-specificity rule. Among `!important` rules, specificity still wins.
|
||||
|
||||
## When to add to L1/L2 paired rules
|
||||
|
||||
If your rule paints `background`, `background-color`, or `background-image` on **any** of:
|
||||
|
||||
```
|
||||
body, html, .backgroundContainer, .skinBody, .mainAnimatedPage, .mainAnimatedPages,
|
||||
.pageContainer, #reactRoot, .videoPlayerContainer, #videoOsdPage, .libraryPage,
|
||||
video.htmlvideoplayer, .emby-scroller, .backdropContainer
|
||||
```
|
||||
|
||||
→ Add the selector to **BOTH** lists in `bin/inject-middle-theme.py`:
|
||||
|
||||
- **L1 list** — under `/* --- L1: PURE-BLACK BG (off-video only) ------ */`, prefixed with `body.arrflix-themed:not(.arrflix-video-active)`.
|
||||
- **L2 list** — under the L2 transparent block, prefixed with `body.arrflix-themed.arrflix-video-active`, value `background:transparent !important`.
|
||||
|
||||
Always paired. Off-video must stay opaque black; on-video must be transparent so `<video>` pixels show through.
|
||||
|
||||
## Safe-edit recipe — "make the search input focus ring red"
|
||||
|
||||
```bash
|
||||
# 1. Edit the injector (NOT the deployed overlay)
|
||||
$EDITOR /tmp/arrflix-recon/bin/inject-middle-theme.py
|
||||
# Add inside the CSS string, search-input section:
|
||||
# body.arrflix-themed .searchFields input:focus {
|
||||
# border-color: #E50914 !important;
|
||||
# box-shadow: 0 0 0 2px rgba(229,9,20,.35) !important;
|
||||
# }
|
||||
# Specificity (0,2,1) — does NOT touch a <video> ancestor → no L1/L2 pairing needed.
|
||||
|
||||
# 2. Regenerate the overlay
|
||||
cd /tmp/arrflix-recon && python3 bin/inject-middle-theme.py
|
||||
|
||||
# 3. Sanity: exactly one marker block
|
||||
grep -c ARRFLIX-MIDDLE-THEME-BEGIN web-overrides/index.html # = 1
|
||||
|
||||
# 4. Push to dev only
|
||||
scp web-overrides/index.html nullstone:/opt/docker/jellyfin-dev/web-overrides/index.html
|
||||
ssh nullstone 'docker restart jellyfin-dev'
|
||||
|
||||
# 5. Verify served
|
||||
curl -s https://dev.arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN # = 1
|
||||
|
||||
# 6. Hard-refresh browser, run testing/SMOKE-TEST.md, then promote per testing/DEPLOY.md.
|
||||
```
|
||||
|
||||
## How to add a new skin variant
|
||||
|
||||
Skin variants are alternative CSS blocks that swap a single visual concern (selector highlight, header logo treatment, etc.) without forking the whole theme.
|
||||
|
||||
- Location: `web-overrides/skins/`.
|
||||
- Naming: `<concern>-variant-<NN>-<short-slug>.css` (e.g. `selector-variant-02-red-underline.css`).
|
||||
- Format: file-level comment header explaining what concern it replaces, which variant is currently active in `inject-middle-theme.py`, and a "drop into the CSS string" instruction. Body is plain CSS scoped under `body.arrflix-themed …`.
|
||||
- Activation: copy the rules into the matching section of `bin/inject-middle-theme.py`, regen overlay, deploy. Skins are NOT auto-loaded — the file is a parking spot.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **camelCase vs lowercase classes** — `.htmlVideoPlayer` does NOT exist; the real class is `.htmlvideoplayer`. Same trap on `.videoOsdBottom` (correct) vs `.videoosdbottom` (wrong).
|
||||
- **Cineplex CSS load order** — `branding.xml` → `@import url('/web/cineplex.css')` is injected as a `<style>` AFTER our inline block. On equal specificity Cineplex wins. Bump specificity, do NOT reorder.
|
||||
- **`branding.xml` XML parse** — `<CustomCss>` content must be XML-safe. Escape `<` `>` in any CSS comment that mentions HTML tags. Silent failure = whole branding skipped.
|
||||
- **iframes / shadow DOM** — Jellyfin web does not currently use either. N/A; skip.
|
||||
- **`backdrop-filter: blur()`** — only renders if there's content scrolling/painted behind the fixed element. On a pure-black bg the blur is invisible (no pixel diff). Test on a page with a poster backdrop.
|
||||
- **`getComputedStyle(html).backgroundColor` returns `rgba(0,0,0,0)`** despite stylesheet rules — Chromium root-canvas quirk. We pin `<html>` via JS `style.setProperty('background-color','#000','important')`. Don't fight it from CSS.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/31-theme-layer-model-and-edit-guide.md` — canonical layer model and history of past incidents.
|
||||
- `testing/ERROR-PATTERNS.md` — catalog of past mistakes (INC1–INC7, v6-stable, image-12).
|
||||
- `testing/SMOKE-TEST.md` — 4-step manual verify after any theme change.
|
||||
- `testing/HEADLESS-PROBE.md` — Playwright recipes for DOM / `darkPct` / OSD-visible assertions.
|
||||
- `testing/DEPLOY.md` / `testing/ROLLBACK.md` — promote-to-prod and revert procedures.
|
||||
0
testing/incidents/.gitkeep
Normal file
0
testing/incidents/.gitkeep
Normal file
0
testing/recipes/.gitkeep
Normal file
0
testing/recipes/.gitkeep
Normal file
0
testing/snippets/.gitkeep
Normal file
0
testing/snippets/.gitkeep
Normal file
735
web-overrides/index-dev.html
Normal file
735
web-overrides/index-dev.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
37
web-overrides/popup-designs/README.md
Normal file
37
web-overrides/popup-designs/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Next-episode popup designs
|
||||
|
||||
Side-by-side preview of the 4 candidate popup designs evaluated 2026-05-10.
|
||||
Owner picked **A · Cinematic Strip** for prod-bound shim. **B · Terminal**
|
||||
and **C · Minimal Bar** archived as standalone files for future re-evaluation
|
||||
(e.g. when ARRFLIX visual direction shifts or owner gets bored of A).
|
||||
|
||||
## Files
|
||||
|
||||
- `preview.html` — full 4-up preview page (drop into `python3 -m http.server`
|
||||
to compare). Designed at 1920×1080 in a faux Star-Wars-credits backdrop.
|
||||
- `a-strip.html` — standalone A. Full-bleed bottom strip, big countdown
|
||||
ring, white "Start Now" CTA, "Hide" secondary. Netflix-grade muscle
|
||||
memory. **Currently shipped to dev.**
|
||||
- `b-terminal.html` — standalone B. Bottom-right card with JetBrains
|
||||
Mono, dashed dividers, red accent line. Edgy, ARRFLIX-distinct.
|
||||
- `c-minimal.html` — standalone C. Thin progress bar across bottom + small
|
||||
text strip, no card. Disappears into UX. Power-user.
|
||||
|
||||
D · Poster Card was discarded — too similar to Jellyfin stock to justify
|
||||
shipping.
|
||||
|
||||
## Wiring (current state)
|
||||
|
||||
Design A is shipped to **dev only** (`dev.arrflix.s8n.ru`) as a CSS+JS
|
||||
shim bracketed in `/opt/docker/jellyfin-dev/web-overrides/index-dev.html`
|
||||
between `NEXT-EP-POPUP-BEGIN` and `NEXT-EP-POPUP-END` markers. The shim
|
||||
keeps Jellyfin's `.upNextDialog` DOM intact (so its countdown timer keeps
|
||||
ticking and clicking the underlying buttons stays wired) and overlays
|
||||
Design A's visual via CSS + a small countdown-ring SVG that mirrors
|
||||
`.upNextDialog-countdownText`.
|
||||
|
||||
Promote to prod when satisfied: copy the shim block into prod's
|
||||
`web-overrides/index.html` between the same markers, then deploy via
|
||||
the same nsenter trick documented in `bin/revert-sub-label-shim.sh`.
|
||||
|
||||
Revert (dev): `bin/revert-next-ep-popup.sh`.
|
||||
42
web-overrides/popup-designs/a-strip.html
Normal file
42
web-overrides/popup-designs/a-strip.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><title>ARRFLIX popup — A · Cinematic Strip</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root { --arrflix-red:#E50914; --ink:#fff; --ink-dim:rgba(255,255,255,0.55); --ink-faint:rgba(255,255,255,0.3); }
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{background:#000;color:#fff;font-family:'Geist',system-ui,sans-serif;height:100vh;overflow:hidden;position:relative;}
|
||||
.bg{position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 50% 50%,rgba(20,20,40,0.4) 0%,transparent 70%),black;}
|
||||
.popup{position:absolute;bottom:0;left:0;right:0;height:26%;background:linear-gradient(to top,rgba(0,0,0,0.95) 50%,rgba(0,0,0,0.7) 80%,transparent);padding:28px 56px 32px;display:flex;align-items:center;gap:36px;}
|
||||
.ring{position:relative;width:88px;height:88px;flex-shrink:0;}
|
||||
.ring svg{transform:rotate(-90deg);}
|
||||
.ring circle{fill:none;stroke-width:3;}
|
||||
.ring .track{stroke:rgba(255,255,255,0.1);}
|
||||
.ring .progress{stroke:var(--arrflix-red);stroke-dasharray:264;stroke-dashoffset:67;stroke-linecap:round;filter:drop-shadow(0 0 8px rgba(229,9,20,0.5));}
|
||||
.ring .num{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:28px;font-weight:500;}
|
||||
.info{flex:1;min-width:0;}
|
||||
.label{font-size:10px;letter-spacing:0.32em;text-transform:uppercase;color:var(--arrflix-red);margin-bottom:8px;font-weight:600;}
|
||||
.title{font-size:28px;font-weight:600;letter-spacing:-0.02em;margin-bottom:6px;line-height:1.1;}
|
||||
.episode-title{font-size:16px;color:var(--ink-dim);margin-bottom:4px;}
|
||||
.meta{font-size:12px;color:var(--ink-faint);letter-spacing:0.04em;}
|
||||
.actions{display:flex;gap:10px;flex-shrink:0;}
|
||||
.btn{border:none;background:white;color:black;padding:14px 28px;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:10px;}
|
||||
.btn:hover{background:rgba(255,255,255,0.85);}
|
||||
.btn-secondary{background:rgba(255,255,255,0.08);color:white;backdrop-filter:blur(10px);}
|
||||
.btn-secondary:hover{background:rgba(255,255,255,0.16);}
|
||||
</style></head><body>
|
||||
<div class="bg"></div>
|
||||
<div class="popup">
|
||||
<div class="ring"><svg width="88" height="88" viewBox="0 0 88 88"><circle class="track" cx="44" cy="44" r="42"/><circle class="progress" cx="44" cy="44" r="42"/></svg><div class="num">17</div></div>
|
||||
<div class="info">
|
||||
<div class="label">Up Next</div>
|
||||
<div class="title">Star Wars: Maul · Shadow Lord</div>
|
||||
<div class="episode-title">S1·E3 — Chapter 3: The Crucible</div>
|
||||
<div class="meta">22 min · Ends at 2:49 AM</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn">▶ Start Now</button>
|
||||
<button class="btn btn-secondary">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
40
web-overrides/popup-designs/b-terminal.html
Normal file
40
web-overrides/popup-designs/b-terminal.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><title>ARRFLIX popup — B · Terminal Card</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--arrflix-red:#E50914;--arrflix-red-dark:#B00710;--ink:#fff;--ink-dim:rgba(255,255,255,0.55);--ink-faint:rgba(255,255,255,0.3);}
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{background:#000;color:#fff;font-family:'JetBrains Mono',monospace;height:100vh;overflow:hidden;position:relative;}
|
||||
.bg{position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 50% 50%,rgba(20,20,40,0.4) 0%,transparent 70%),black;}
|
||||
.popup{position:absolute;bottom:32px;right:32px;width:380px;background:rgba(5,5,5,0.92);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,0.12);border-left:2px solid var(--arrflix-red);padding:20px 22px;}
|
||||
.top-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;font-size:10px;letter-spacing:0.18em;text-transform:uppercase;}
|
||||
.tag{color:var(--arrflix-red);font-weight:700;}
|
||||
.countdown{color:var(--ink-dim);}
|
||||
.countdown strong{color:var(--ink);font-weight:700;}
|
||||
hr{border:none;border-top:1px dashed rgba(255,255,255,0.1);margin:12px 0;}
|
||||
.show{font-family:'Geist',sans-serif;font-size:11px;letter-spacing:0.18em;text-transform:uppercase;color:var(--ink-faint);margin-bottom:4px;}
|
||||
.ep{font-family:'Geist',sans-serif;font-size:15px;font-weight:500;margin-bottom:4px;color:var(--ink);}
|
||||
.ep-meta{font-size:11px;color:var(--ink-faint);letter-spacing:0.06em;margin-bottom:16px;}
|
||||
.progress-bar{height:2px;background:rgba(255,255,255,0.08);margin-bottom:18px;position:relative;}
|
||||
.progress-bar::after{content:'';position:absolute;top:0;left:0;height:100%;width:75%;background:var(--arrflix-red);box-shadow:0 0 8px rgba(229,9,20,0.6);}
|
||||
.actions{display:flex;gap:8px;}
|
||||
.btn{flex:1;background:transparent;border:1px solid rgba(255,255,255,0.18);color:white;padding:9px 14px;font-family:inherit;font-size:11px;letter-spacing:0.18em;text-transform:uppercase;cursor:pointer;}
|
||||
.btn-primary{background:var(--arrflix-red);border-color:var(--arrflix-red);}
|
||||
.btn-primary:hover{background:var(--arrflix-red-dark);}
|
||||
.btn:hover{background:rgba(255,255,255,0.06);border-color:rgba(255,255,255,0.3);}
|
||||
</style></head><body>
|
||||
<div class="bg"></div>
|
||||
<div class="popup">
|
||||
<div class="top-row"><span class="tag">▎ Up Next</span><span class="countdown"><strong>17</strong>s</span></div>
|
||||
<hr>
|
||||
<div class="show">Star Wars: Maul · Shadow Lord · S1E3</div>
|
||||
<div class="ep">Chapter 3: The Crucible</div>
|
||||
<div class="ep-meta">22m / ends 02:49</div>
|
||||
<div class="progress-bar"></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">▶ Start now</button>
|
||||
<button class="btn">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
36
web-overrides/popup-designs/c-minimal.html
Normal file
36
web-overrides/popup-designs/c-minimal.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><title>ARRFLIX popup — C · Minimal Bar</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@500&family=Bebas+Neue&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--arrflix-red:#E50914;--ink:#fff;--ink-dim:rgba(255,255,255,0.55);}
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{background:#000;color:#fff;font-family:'Geist',system-ui,sans-serif;height:100vh;overflow:hidden;position:relative;}
|
||||
.bg{position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 50% 50%,rgba(20,20,40,0.4) 0%,transparent 70%),black;}
|
||||
.popup{position:absolute;bottom:0;left:0;right:0;}
|
||||
.progress-line{height:3px;background:rgba(255,255,255,0.1);position:relative;}
|
||||
.progress-line::after{content:'';position:absolute;top:0;left:0;height:100%;width:75%;background:var(--arrflix-red);}
|
||||
.row{background:linear-gradient(to bottom,transparent,rgba(0,0,0,0.85) 30%);padding:24px 56px 22px;display:flex;align-items:center;gap:24px;}
|
||||
.label{font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:0.32em;color:var(--arrflix-red);flex-shrink:0;}
|
||||
.text{flex:1;font-size:14px;color:var(--ink-dim);letter-spacing:0.02em;}
|
||||
.text strong{color:var(--ink);font-weight:500;margin-right:8px;}
|
||||
.text .countdown{color:var(--arrflix-red);font-family:'JetBrains Mono',monospace;font-weight:500;margin-left:8px;}
|
||||
.actions{display:flex;gap:16px;}
|
||||
.btn{background:transparent;border:none;color:white;font-family:inherit;font-size:12px;letter-spacing:0.2em;text-transform:uppercase;font-weight:600;cursor:pointer;padding:8px 0;position:relative;}
|
||||
.btn::after{content:'';position:absolute;left:0;bottom:0;height:1px;width:100%;background:currentColor;opacity:0.3;}
|
||||
.btn:hover::after{opacity:1;}
|
||||
.btn-primary{color:var(--arrflix-red);}
|
||||
</style></head><body>
|
||||
<div class="bg"></div>
|
||||
<div class="popup">
|
||||
<div class="progress-line"></div>
|
||||
<div class="row">
|
||||
<div class="label">UP NEXT</div>
|
||||
<div class="text"><strong>Star Wars: Maul · Shadow Lord</strong>S1·E3 — Chapter 3: The Crucible<span class="countdown">00:17</span></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">Start now ▶</button>
|
||||
<button class="btn">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
760
web-overrides/popup-designs/preview.html
Normal file
760
web-overrides/popup-designs/preview.html
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ARRFLIX — Next-Episode popup designs</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Bebas+Neue&family=Anton&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--arrflix-red: #E50914;
|
||||
--arrflix-red-dark: #B00710;
|
||||
--arrflix-bg: #0a0a0a;
|
||||
--ink: #fff;
|
||||
--ink-dim: rgba(255,255,255,0.55);
|
||||
--ink-faint: rgba(255,255,255,0.3);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: #050505;
|
||||
color: var(--ink);
|
||||
font-family: 'Geist', system-ui, sans-serif;
|
||||
font-feature-settings: "ss01", "ss02";
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.header {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
background: rgba(5,5,5,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 18px 28px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.header h1 {
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 20px;
|
||||
color: var(--arrflix-red);
|
||||
}
|
||||
.header .meta {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.picker {
|
||||
display: flex; gap: 10px;
|
||||
}
|
||||
.picker button {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: var(--ink-dim);
|
||||
padding: 8px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.picker button:hover {
|
||||
border-color: var(--arrflix-red);
|
||||
color: var(--ink);
|
||||
}
|
||||
.picker button.active {
|
||||
background: var(--arrflix-red);
|
||||
border-color: var(--arrflix-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
max-height: calc(100vh - 80px);
|
||||
background: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage::before {
|
||||
/* Star Wars credits backdrop */
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(20,20,40,0.4) 0%, transparent 70%),
|
||||
black;
|
||||
}
|
||||
.stars {
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
radial-gradient(1px 1px at 20% 30%, white, transparent),
|
||||
radial-gradient(1px 1px at 65% 50%, white, transparent),
|
||||
radial-gradient(1px 1px at 80% 20%, rgba(255,255,255,0.7), transparent),
|
||||
radial-gradient(2px 2px at 10% 70%, white, transparent),
|
||||
radial-gradient(1px 1px at 85% 80%, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 45% 90%, white, transparent),
|
||||
radial-gradient(1px 1px at 30% 15%, rgba(255,255,255,0.5), transparent),
|
||||
radial-gradient(1.5px 1.5px at 70% 75%, white, transparent),
|
||||
radial-gradient(1px 1px at 5% 45%, white, transparent),
|
||||
radial-gradient(1px 1px at 95% 60%, rgba(255,255,255,0.6), transparent),
|
||||
radial-gradient(1px 1px at 25% 85%, white, transparent),
|
||||
radial-gradient(1px 1px at 55% 25%, white, transparent),
|
||||
radial-gradient(2px 2px at 50% 50%, rgba(255,255,255,0.4), transparent),
|
||||
radial-gradient(1px 1px at 15% 92%, white, transparent),
|
||||
radial-gradient(1px 1px at 88% 35%, white, transparent);
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
.credits {
|
||||
position: absolute;
|
||||
top: 18%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #4488dd;
|
||||
font-family: 'Geist', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
opacity: 0.55;
|
||||
text-shadow: 0 0 4px rgba(68,136,221,0.2);
|
||||
}
|
||||
.credits .row {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
|
||||
text-align: left;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.credits .row .role { text-align: right; opacity: 0.85; }
|
||||
.credits .row .name { text-transform: uppercase; }
|
||||
.credits .title-block { text-align: center; margin-bottom: 18px; }
|
||||
|
||||
/* Stop the star backdrop from comUSER-Eing */
|
||||
.stage .popup-host {
|
||||
position: absolute; inset: 0; z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage .popup-host > * { pointer-events: auto; }
|
||||
|
||||
/* === DESIGN A — CINEMATIC STRIP === */
|
||||
.design-a {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 26%;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.95) 50%, rgba(0,0,0,0.7) 80%, transparent);
|
||||
padding: 28px 56px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
transform: translateY(0);
|
||||
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.design-a .ring {
|
||||
position: relative;
|
||||
width: 88px; height: 88px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.design-a .ring svg { transform: rotate(-90deg); }
|
||||
.design-a .ring circle {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.design-a .ring .track { stroke: rgba(255,255,255,0.1); }
|
||||
.design-a .ring .progress {
|
||||
stroke: var(--arrflix-red);
|
||||
stroke-dasharray: 264;
|
||||
stroke-dashoffset: 67;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 1s linear;
|
||||
filter: drop-shadow(0 0 8px rgba(229, 9, 20, 0.5));
|
||||
}
|
||||
.design-a .ring .num {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.design-a .info { flex: 1; min-width: 0; }
|
||||
.design-a .label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--arrflix-red);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.design-a .title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.design-a .episode-title {
|
||||
font-size: 16px;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.design-a .meta {
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.design-a .actions {
|
||||
display: flex; gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.design-a .btn {
|
||||
border: none;
|
||||
background: white;
|
||||
color: black;
|
||||
padding: 14px 28px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.design-a .btn:hover { background: rgba(255,255,255,0.85); }
|
||||
.design-a .btn-secondary {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: white;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.design-a .btn-secondary:hover { background: rgba(255,255,255,0.16); }
|
||||
|
||||
/* === DESIGN B — TERMINAL CARD === */
|
||||
.design-b {
|
||||
position: absolute;
|
||||
bottom: 32px; right: 32px;
|
||||
width: 380px;
|
||||
background: rgba(5,5,5,0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-left: 2px solid var(--arrflix-red);
|
||||
padding: 20px 22px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
animation: slideRight 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@keyframes slideRight {
|
||||
from { transform: translateX(20px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.design-b .top-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 14px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.design-b .tag {
|
||||
color: var(--arrflix-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
.design-b .countdown {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.design-b .countdown strong {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.design-b hr {
|
||||
border: none;
|
||||
border-top: 1px dashed rgba(255,255,255,0.1);
|
||||
margin: 12px 0;
|
||||
}
|
||||
.design-b .ep {
|
||||
font-family: 'Geist', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.design-b .ep-meta {
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.design-b .progress-bar {
|
||||
height: 2px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
}
|
||||
.design-b .progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
height: 100%;
|
||||
width: 75%;
|
||||
background: var(--arrflix-red);
|
||||
box-shadow: 0 0 8px rgba(229,9,20,0.6);
|
||||
animation: fillBar 17s linear forwards;
|
||||
}
|
||||
@keyframes fillBar {
|
||||
to { width: 100%; }
|
||||
}
|
||||
.design-b .actions {
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
.design-b .btn {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
color: white;
|
||||
padding: 9px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.design-b .btn-primary {
|
||||
background: var(--arrflix-red);
|
||||
border-color: var(--arrflix-red);
|
||||
}
|
||||
.design-b .btn-primary:hover { background: var(--arrflix-red-dark); }
|
||||
.design-b .btn-secondary:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-color: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
/* === DESIGN C — MINIMAL BAR === */
|
||||
.design-c {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
pointer-events: auto;
|
||||
animation: fadeUp 0.4s ease;
|
||||
}
|
||||
@keyframes fadeUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.design-c .progress-line {
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
position: relative;
|
||||
}
|
||||
.design-c .progress-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
height: 100%;
|
||||
width: 75%;
|
||||
background: var(--arrflix-red);
|
||||
animation: fillBar 17s linear forwards;
|
||||
}
|
||||
.design-c .row {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.85) 30%);
|
||||
padding: 24px 56px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.design-c .label {
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.32em;
|
||||
color: var(--arrflix-red);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.design-c .text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.design-c .text strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.design-c .text .countdown {
|
||||
color: var(--arrflix-red);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.design-c .actions {
|
||||
display: flex; gap: 16px;
|
||||
}
|
||||
.design-c .btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.design-c .btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; bottom: 0;
|
||||
height: 1px; width: 100%;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.design-c .btn:hover::after { opacity: 1; }
|
||||
.design-c .btn-primary { color: var(--arrflix-red); }
|
||||
|
||||
/* === DESIGN D — POSTER CARD === */
|
||||
.design-d {
|
||||
position: absolute;
|
||||
bottom: 28px; right: 28px;
|
||||
width: 460px;
|
||||
background: linear-gradient(135deg, rgba(15,15,15,0.95), rgba(5,5,5,0.95));
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6),
|
||||
0 0 0 1px rgba(229,9,20,0.15);
|
||||
animation: slideRight 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.design-d .poster {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(229,9,20,0.3), transparent 60%),
|
||||
radial-gradient(circle at 30% 30%, #2a1a1a, #050505);
|
||||
position: relative;
|
||||
display: flex; align-items: flex-end;
|
||||
padding: 16px;
|
||||
}
|
||||
.design-d .poster::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
radial-gradient(1px 1px at 20% 30%, white, transparent),
|
||||
radial-gradient(1px 1px at 70% 50%, white, transparent),
|
||||
radial-gradient(1px 1px at 40% 70%, white, transparent),
|
||||
radial-gradient(1px 1px at 80% 80%, white, transparent),
|
||||
radial-gradient(1px 1px at 50% 20%, white, transparent),
|
||||
radial-gradient(1.5px 1.5px at 60% 60%, white, transparent);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.design-d .poster .ep-num {
|
||||
position: relative;
|
||||
font-family: 'Anton', sans-serif;
|
||||
font-size: 64px;
|
||||
line-height: 0.9;
|
||||
color: var(--arrflix-red);
|
||||
letter-spacing: -0.04em;
|
||||
text-shadow: 0 4px 20px rgba(229,9,20,0.5);
|
||||
}
|
||||
.design-d .body {
|
||||
flex: 1;
|
||||
padding: 18px 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.design-d .top {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.design-d .label {
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--arrflix-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
.design-d .timer {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.design-d .timer strong {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.design-d .show {
|
||||
font-size: 13px;
|
||||
color: var(--ink-faint);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.design-d .ep-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.design-d .meta {
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.design-d .meta .dot { margin: 0 8px; opacity: 0.5; }
|
||||
.design-d .actions {
|
||||
display: flex; gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.design-d .btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 11px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.design-d .btn-primary {
|
||||
background: var(--arrflix-red);
|
||||
color: white;
|
||||
box-shadow: 0 4px 14px rgba(229,9,20,0.3);
|
||||
}
|
||||
.design-d .btn-primary:hover { background: var(--arrflix-red-dark); }
|
||||
.design-d .btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
.design-d .btn-secondary:hover {
|
||||
color: var(--ink);
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
}
|
||||
|
||||
/* hidden helper */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* design label */
|
||||
.design-label {
|
||||
position: absolute;
|
||||
top: 24px; left: 28px;
|
||||
z-index: 60;
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
border-left: 2px solid var(--arrflix-red);
|
||||
padding-left: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.design-label strong {
|
||||
display: block;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.design-label .desc {
|
||||
font-family: 'Geist', sans-serif;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: none;
|
||||
color: var(--ink-dim);
|
||||
margin-top: 4px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 28px 56px 36px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.7;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.footer strong { color: var(--ink-dim); font-weight: 500; }
|
||||
.footer kbd {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<h1>ARRFLIX · NEXT-EPISODE POPUP</h1>
|
||||
<div class="picker">
|
||||
<button data-d="a" class="active">A · STRIP</button>
|
||||
<button data-d="b">B · TERMINAL</button>
|
||||
<button data-d="c">C · MINIMAL</button>
|
||||
<button data-d="d">D · POSTER</button>
|
||||
</div>
|
||||
<div class="meta">PICK ONE · 2026-05-10</div>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
<div class="stars"></div>
|
||||
<div class="credits">
|
||||
<div class="title-block" style="color:#4488dd; font-weight:600;">
|
||||
Production Services Provided by CGCG, Inc.
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="role">Lighting Director<br>Lighting Lead<br>Lighting Artists</div>
|
||||
<div class="name">Chung-Kai Hsueh<br>Yin-Jung Huang<br>Jung-Tzu Chang · Po-Jui Chiu<br>Char Ho · Luna Jiang<br>Chuan-Sheng Lan · Po-Yu Li</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="role">Special Effects Director<br>Special Effects Artists</div>
|
||||
<div class="name">Chia-Hung Chu<br>Jia-You Cai · Lin-Chi Chen<br>Cai-Jhu Li · Zhi-Hao Liu</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="role">Production Technology</div>
|
||||
<div class="name">Indigo Tang · Joe Chang<br>I Chiang · Chih-Chiang Tsai</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="design-label" id="dlabel">
|
||||
<strong>A · CINEMATIC STRIP</strong>
|
||||
<div class="desc">Full-bleed bottom strip. Big countdown ring. White CTA = Netflix muscle memory. Grand.</div>
|
||||
</div>
|
||||
|
||||
<div class="popup-host">
|
||||
|
||||
<!-- DESIGN A -->
|
||||
<div class="design-a" data-design="a">
|
||||
<div class="ring">
|
||||
<svg width="88" height="88" viewBox="0 0 88 88">
|
||||
<circle class="track" cx="44" cy="44" r="42"/>
|
||||
<circle class="progress" cx="44" cy="44" r="42"/>
|
||||
</svg>
|
||||
<div class="num">17</div>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="label">Up Next</div>
|
||||
<div class="title">Star Wars: Maul · Shadow Lord</div>
|
||||
<div class="episode-title">S1·E3 — Chapter 3: The Crucible</div>
|
||||
<div class="meta">22 min · Ends at 2:49 AM</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn">▶ Start Now</button>
|
||||
<button class="btn btn-secondary">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESIGN B -->
|
||||
<div class="design-b hidden" data-design="b">
|
||||
<div class="top-row">
|
||||
<span class="tag">▎ Up Next</span>
|
||||
<span class="countdown"><strong>17</strong>s</span>
|
||||
</div>
|
||||
<hr>
|
||||
<div style="font-family:'Geist',sans-serif; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-faint); margin-bottom: 4px;">Star Wars: Maul · Shadow Lord · S1E3</div>
|
||||
<div class="ep">Chapter 3: The Crucible</div>
|
||||
<div class="ep-meta">22m / ends 02:49</div>
|
||||
<div class="progress-bar"></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">▶ Start now</button>
|
||||
<button class="btn btn-secondary">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESIGN C -->
|
||||
<div class="design-c hidden" data-design="c">
|
||||
<div class="progress-line"></div>
|
||||
<div class="row">
|
||||
<div class="label">UP NEXT</div>
|
||||
<div class="text">
|
||||
<strong>Star Wars: Maul · Shadow Lord</strong>S1·E3 — Chapter 3: The Crucible
|
||||
<span class="countdown">00:17</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">Start now ▶</button>
|
||||
<button class="btn">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESIGN D -->
|
||||
<div class="design-d hidden" data-design="d">
|
||||
<div class="poster"><div class="ep-num">E3</div></div>
|
||||
<div class="body">
|
||||
<div class="top">
|
||||
<div class="label">Up Next</div>
|
||||
<div class="timer">in <strong>17</strong>s</div>
|
||||
</div>
|
||||
<div class="show">Star Wars: Maul · Shadow Lord</div>
|
||||
<div class="ep-title">Chapter 3: The Crucible</div>
|
||||
<div class="meta">Season 1 <span class="dot">·</span> 22 min <span class="dot">·</span> Ends 02:49</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">▶ Start now</button>
|
||||
<button class="btn btn-secondary">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Pick one (<kbd>1</kbd>–<kbd>4</kbd> or click). When approved, design ships as a JS shim into <strong>web-overrides/index.html</strong> bracketed by <strong>NEXT-EP-POPUP-BEGIN/END</strong> markers, with one-shot revert via <strong>bin/revert-next-ep-popup.sh</strong> (matching the sub-label-shim pattern). The shim hides Jellyfin's stock card and renders the chosen design in its place when Jellyfin signals an upcoming episode.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const labels = {
|
||||
a: ['A · CINEMATIC STRIP', 'Full-bleed bottom strip. Big countdown ring. White CTA = Netflix muscle memory. Grand.'],
|
||||
b: ['B · TERMINAL CARD', 'Bottom-right card with monospace, dashed dividers, red accent line. Edgy, ARRFLIX-distinct.'],
|
||||
c: ['C · MINIMAL BAR', 'Thin progress line + small text strip across bottom. Disappears into UX. Power-user.'],
|
||||
d: ['D · POSTER CARD', 'Bottom-right card with episode-number tile + show + ep title + dual buttons. Polished, pragmatic.'],
|
||||
};
|
||||
const buttons = document.querySelectorAll('.picker button');
|
||||
const designs = document.querySelectorAll('[data-design]');
|
||||
const lbl = document.getElementById('dlabel');
|
||||
function show(d) {
|
||||
buttons.forEach(b => b.classList.toggle('active', b.dataset.d === d));
|
||||
designs.forEach(el => el.classList.toggle('hidden', el.dataset.design !== d));
|
||||
lbl.querySelector('strong').textContent = labels[d][0];
|
||||
lbl.querySelector('.desc').textContent = labels[d][1];
|
||||
// restart fillBar animations on switch (recreate elements)
|
||||
designs.forEach(el => {
|
||||
if (el.dataset.design === d) {
|
||||
el.style.animation = 'none';
|
||||
void el.offsetHeight;
|
||||
el.style.animation = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
buttons.forEach(b => b.addEventListener('click', () => show(b.dataset.d)));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const map = { '1':'a', '2':'b', '3':'c', '4':'d' };
|
||||
if (map[e.key]) show(map[e.key]);
|
||||
});
|
||||
|
||||
// animated countdown
|
||||
let t = 17;
|
||||
setInterval(() => {
|
||||
t--;
|
||||
if (t < 0) t = 17;
|
||||
document.querySelector('.design-a .num').textContent = t;
|
||||
document.querySelector('.design-a .ring .progress').style.strokeDashoffset = 67 + (264 - 67) * (1 - t/17);
|
||||
document.querySelector('.design-b .top-row strong').textContent = t;
|
||||
document.querySelector('.design-c .countdown').textContent = '00:' + String(t).padStart(2,'0');
|
||||
document.querySelector('.design-d .timer strong').textContent = t;
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
968
web-overrides/skins/detail-variant-01-netflix-cinema.html
Normal file
968
web-overrides/skins/detail-variant-01-netflix-cinema.html
Normal file
File diff suppressed because one or more lines are too long
26
web-overrides/skins/selector-variant-02-red-underline.css
Normal file
26
web-overrides/skins/selector-variant-02-red-underline.css
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* ARRFLIX skin variant — selector dropdown highlight: "Red underline"
|
||||
*
|
||||
* Alt design for the audio/subtitle dropdown selected-row highlight (and any
|
||||
* other actionSheet/listItem picker). Currently NOT applied — variant 04
|
||||
* "Hairline ring" is the active design (see bin/inject-middle-theme.py).
|
||||
*
|
||||
* Saved for future skin/swap option per owner request 2026-05-10.
|
||||
*
|
||||
* Drop into the CSS string in bin/inject-middle-theme.py to swap. Replace the
|
||||
* current variant-04 block at the end of the CSS section.
|
||||
*
|
||||
* Look: faint red wash + 2px red bottom border + soft red glow. Mirrors our
|
||||
* search-input focus treatment for visual consistency.
|
||||
*/
|
||||
|
||||
body.arrflix-themed .actionSheet .listItem.selected,
|
||||
body.arrflix-themed .actionSheet .listItem-button.selected,
|
||||
body.arrflix-themed .actionSheet .listItem.focused,
|
||||
body.arrflix-themed .selectionList .listItem.selected,
|
||||
body.arrflix-themed .dialogContainer .listItem.selected {
|
||||
background: rgba(229, 9, 20, 0.04) !important;
|
||||
color: #fff !important;
|
||||
border-bottom: 2px solid #E50914 !important;
|
||||
box-shadow: 0 1px 0 rgba(229, 9, 20, 0.35);
|
||||
outline: none !important;
|
||||
}
|
||||
1
web-overrides/theater-design/arrflix-logo.b64
Normal file
1
web-overrides/theater-design/arrflix-logo.b64
Normal file
File diff suppressed because one or more lines are too long
BIN
web-overrides/theater-design/arrflix-logo.png
Normal file
BIN
web-overrides/theater-design/arrflix-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
848
web-overrides/theater-design/picker.html
Normal file
848
web-overrides/theater-design/picker.html
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ARRFLIX Login — Picker R3</title>
|
||||
<style>
|
||||
:root {
|
||||
--black: #000000;
|
||||
--white: #ffffff;
|
||||
--red: #E50914;
|
||||
--red-dim: #B0070F;
|
||||
--font-display: "Bebas Neue", "Anton", Impact, "Haettenschweiler", "Liberation Sans Narrow", "Arial Narrow Bold", "Helvetica Neue Condensed Black", "DejaVu Sans Condensed", sans-serif;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, "Liberation Sans", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", "Monaco", "Cascadia Code", "JetBrains Mono", "Roboto Mono", "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
background: #050505;
|
||||
color: #fff;
|
||||
font-family: var(--font-body);
|
||||
overflow-x: auto;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* === Picker page chrome === */
|
||||
.picker-header {
|
||||
padding: 56px 72px 40px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
min-width: 2240px;
|
||||
background:
|
||||
radial-gradient(ellipse at top right, rgba(229,9,20,0.05) 0%, transparent 50%),
|
||||
linear-gradient(180deg, #000 0%, #050505 100%);
|
||||
position: relative;
|
||||
}
|
||||
.picker-header::after {
|
||||
content: ""; position: absolute;
|
||||
left: 72px; bottom: -1px;
|
||||
width: 120px; height: 3px; background: var(--red);
|
||||
}
|
||||
.picker-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 80px;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 0.9;
|
||||
color: var(--red);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.picker-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.picker-sub .sep { color: var(--red); margin: 0 14px; }
|
||||
.picker-meta {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
gap: 56px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.picker-meta span strong { color: #ccc; margin-left: 8px; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 64px 56px;
|
||||
padding: 72px;
|
||||
width: 2240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.variant { display: flex; flex-direction: column; gap: 20px; }
|
||||
.variant-label { display: flex; align-items: baseline; gap: 22px; padding: 0 4px; }
|
||||
.variant-num {
|
||||
font-family: var(--font-display);
|
||||
font-size: 60px;
|
||||
color: var(--red);
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.variant-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 34px;
|
||||
color: #fff;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.variant-tag {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.viewport {
|
||||
width: 960px;
|
||||
height: 540px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid #1f1f1f;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.85), 0 0 0 1px rgba(229,9,20,0.04);
|
||||
background: #000;
|
||||
}
|
||||
.login-page {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
}
|
||||
.variant-desc {
|
||||
font-size: 13.5px;
|
||||
color: #888;
|
||||
line-height: 1.65;
|
||||
padding: 0 4px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.variant-desc strong { color: #ddd; font-weight: 600; }
|
||||
.variant-desc .red { color: var(--red); font-weight: 700; }
|
||||
|
||||
.arrflix-img { display: block; image-rendering: -webkit-optimize-contrast; user-select: none; }
|
||||
|
||||
/* === Shared top-bar (centered logo) — all variants === */
|
||||
.topbar {
|
||||
position: relative; z-index: 3;
|
||||
height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* === Shared form-row (centered) defaults === */
|
||||
.row-c { display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* ========================================================
|
||||
VARIANT 1 — THE THEATER (dark rectangular card)
|
||||
======================================================== */
|
||||
.v1 {
|
||||
background: url("poster-bg.jpg") center / cover no-repeat, #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v1::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 60% at center,
|
||||
rgba(0,0,0,0) 0%,
|
||||
rgba(0,0,0,0.55) 55%,
|
||||
rgba(0,0,0,0.92) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 25%, rgba(0,0,0,0) 75%, rgba(0,0,0,0.7) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v1 .topbar { background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%); }
|
||||
.v1 .topbar .arrflix-img { width: 156px; height: auto; }
|
||||
.v1 .stage {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: calc(100% - 88px);
|
||||
}
|
||||
.v1 .card {
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
width: 540px;
|
||||
padding: 56px 56px 44px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.v1 .card h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 48px;
|
||||
color: #fff;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 36px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.v1 .field { margin-bottom: 24px; }
|
||||
.v1 .field label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.v1 .field input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.18);
|
||||
padding: 12px 0 14px;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.v1 .field input:focus { border-bottom-color: var(--red); }
|
||||
.v1 .row { margin: 30px 0; }
|
||||
.v1 .check {
|
||||
width: 20px; height: 20px;
|
||||
background: var(--red);
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.v1 .check::after {
|
||||
content: ""; position: absolute;
|
||||
top: 5px; left: 4px; width: 11px; height: 5px;
|
||||
border-left: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.v1 .remember-label { font-size: 14px; color: #ccc; }
|
||||
.v1 .btn {
|
||||
width: 100%;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
padding: 18px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.v1 .disclaimer {
|
||||
margin-top: 28px;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ========================================================
|
||||
VARIANT 2 — THE MARQUEE (neon-red outlined card, glowing)
|
||||
======================================================== */
|
||||
.v2 {
|
||||
background: url("poster-bg.jpg") center / cover no-repeat, #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v2::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at center, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.92) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 25%, rgba(0,0,0,0) 75%, rgba(0,0,0,0.8) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v2 .topbar { background: linear-gradient(180deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0) 100%); }
|
||||
.v2 .topbar .arrflix-img { width: 156px; height: auto; }
|
||||
.v2 .stage {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: calc(100% - 88px);
|
||||
}
|
||||
.v2 .marquee {
|
||||
width: 520px;
|
||||
padding: 52px 48px 44px;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 2px solid var(--red);
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(229,9,20,0.3),
|
||||
0 0 32px rgba(229,9,20,0.45),
|
||||
0 0 80px rgba(229,9,20,0.25),
|
||||
inset 0 0 24px rgba(229,9,20,0.06);
|
||||
}
|
||||
/* Corner L-bracket accents */
|
||||
.v2 .marquee .corner {
|
||||
position: absolute;
|
||||
width: 18px; height: 18px;
|
||||
border: 2px solid var(--red);
|
||||
background: #000;
|
||||
}
|
||||
.v2 .marquee .corner.tl { top: -10px; left: -10px; border-right: none; border-bottom: none; }
|
||||
.v2 .marquee .corner.tr { top: -10px; right: -10px; border-left: none; border-bottom: none; }
|
||||
.v2 .marquee .corner.bl { bottom: -10px; left: -10px; border-right: none; border-top: none; }
|
||||
.v2 .marquee .corner.br { bottom: -10px; right: -10px; border-left: none; border-top: none; }
|
||||
/* Top-edge "marquee bulbs" — series of red dots above the card */
|
||||
.v2 .marquee .bulbs {
|
||||
position: absolute;
|
||||
top: -28px;
|
||||
left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 12px;
|
||||
}
|
||||
.v2 .marquee .bulbs span {
|
||||
width: 6px; height: 6px;
|
||||
background: var(--red);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(229,9,20,0.9);
|
||||
}
|
||||
.v2 .marquee .bulbs span:nth-child(odd) { opacity: 0.45; }
|
||||
.v2 .marquee h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 46px;
|
||||
color: #fff;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 24px rgba(229,9,20,0.4);
|
||||
}
|
||||
.v2 .marquee .sub {
|
||||
text-align: center;
|
||||
color: var(--red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.45em;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.v2 .field { margin-bottom: 22px; }
|
||||
.v2 .field label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.v2 .field input {
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(229,9,20,0.3);
|
||||
padding: 14px 16px;
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.v2 .field input:focus {
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 0 16px rgba(229,9,20,0.4);
|
||||
}
|
||||
.v2 .row { margin: 26px 0 28px; }
|
||||
.v2 .check {
|
||||
width: 18px; height: 18px;
|
||||
background: var(--red);
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
box-shadow: 0 0 10px rgba(229,9,20,0.7);
|
||||
}
|
||||
.v2 .check::after {
|
||||
content: ""; position: absolute;
|
||||
top: 4px; left: 3px; width: 10px; height: 5px;
|
||||
border-left: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.v2 .remember-label { font-size: 14px; color: #ddd; }
|
||||
.v2 .btn {
|
||||
width: 100%;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 18px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 0 20px rgba(229,9,20,0.5), inset 0 1px 0 rgba(255,255,255,0.15);
|
||||
}
|
||||
.v2 .disclaimer {
|
||||
margin-top: 22px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-align: center;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.v2 .disclaimer .red { color: var(--red); }
|
||||
|
||||
/* ========================================================
|
||||
VARIANT 3 — THE CINEMA (letterbox bars, wide thin card)
|
||||
======================================================== */
|
||||
.v3 {
|
||||
background: url("poster-bg.jpg") center / cover no-repeat, #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v3::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
rgba(0,0,0,1) 0%,
|
||||
rgba(0,0,0,1) 13%,
|
||||
rgba(0,0,0,0.35) 13%,
|
||||
rgba(0,0,0,0.35) 87%,
|
||||
rgba(0,0,0,1) 87%,
|
||||
rgba(0,0,0,1) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 50% at center, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 80%, rgba(0,0,0,0.9) 100%),
|
||||
linear-gradient(90deg, rgba(229,9,20,0.04) 0%, transparent 30%, transparent 70%, rgba(229,9,20,0.04) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3 .topbar {
|
||||
background: #000;
|
||||
border-bottom: 1px solid rgba(229,9,20,0.4);
|
||||
}
|
||||
.v3 .topbar .lr-bar {
|
||||
display: flex; align-items: center; gap: 32px;
|
||||
}
|
||||
.v3 .topbar .line-left,
|
||||
.v3 .topbar .line-right {
|
||||
width: 120px; height: 1px; background: var(--red);
|
||||
}
|
||||
.v3 .topbar .arrflix-img { width: 156px; height: auto; }
|
||||
.v3 .stage {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: calc(100% - 88px);
|
||||
}
|
||||
.v3 .wide-card {
|
||||
width: 720px;
|
||||
padding: 48px 64px 40px;
|
||||
background: rgba(0,0,0,0.82);
|
||||
border-top: 1px solid rgba(229,9,20,0.6);
|
||||
border-bottom: 1px solid rgba(229,9,20,0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.v3 .wide-card h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 48px;
|
||||
color: #fff;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.v3 .wide-card .divider {
|
||||
width: 60px; height: 2px;
|
||||
background: var(--red);
|
||||
margin: 12px auto 32px;
|
||||
}
|
||||
.v3 .two-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.v3 .field label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.v3 .field input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||
padding: 10px 0 12px;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
.v3 .field input:focus { border-bottom-color: var(--red); }
|
||||
.v3 .row { margin: 24px 0 28px; }
|
||||
.v3 .check {
|
||||
width: 18px; height: 18px;
|
||||
background: var(--red);
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.v3 .check::after {
|
||||
content: ""; position: absolute;
|
||||
top: 4px; left: 3px; width: 10px; height: 5px;
|
||||
border-left: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.v3 .remember-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.v3 .btn {
|
||||
width: 320px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 18px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.v3 .disclaimer {
|
||||
margin-top: 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-align: center;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.v3 .disclaimer .red { color: var(--red); }
|
||||
|
||||
/* ========================================================
|
||||
VARIANT 4 — THE NOIR (heavily dimmed, tight mono card)
|
||||
======================================================== */
|
||||
.v4 {
|
||||
background: url("poster-bg.jpg") center / cover no-repeat, #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v4::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||||
background:
|
||||
radial-gradient(ellipse 50% 50% at center, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.92) 100%);
|
||||
backdrop-filter: grayscale(1) blur(2px);
|
||||
-webkit-backdrop-filter: grayscale(1) blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v4 .topbar {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: rgba(0,0,0,0.55);
|
||||
}
|
||||
.v4 .topbar .arrflix-img { width: 142px; height: auto; }
|
||||
.v4 .stage {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: calc(100% - 88px);
|
||||
}
|
||||
.v4 .tight-card {
|
||||
width: 440px;
|
||||
padding: 56px 48px 44px;
|
||||
background: rgba(8,8,8,0.92);
|
||||
border: 1px solid #1f1f1f;
|
||||
border-top: 2px solid var(--red);
|
||||
position: relative;
|
||||
}
|
||||
.v4 .tight-card::before {
|
||||
content: "ARR · MMXXVI";
|
||||
position: absolute;
|
||||
top: -10px; left: 50%; transform: translateX(-50%);
|
||||
background: #000;
|
||||
padding: 0 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--red);
|
||||
letter-spacing: 0.4em;
|
||||
}
|
||||
.v4 .tight-card h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 38px;
|
||||
color: #fff;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.v4 .tight-card .sub {
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.v4 .field { margin-bottom: 22px; }
|
||||
.v4 .field label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.v4 .field label .num { color: var(--red); font-size: 9px; }
|
||||
.v4 .field input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||
padding: 6px 0 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.05em;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.v4 .field input:focus { border-bottom-color: var(--red); }
|
||||
.v4 .row { margin: 20px 0 28px; }
|
||||
.v4 .check {
|
||||
width: 14px; height: 14px;
|
||||
background: var(--red);
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.v4 .check::after {
|
||||
content: ""; position: absolute;
|
||||
top: 2px; left: 2px; width: 9px; height: 4px;
|
||||
border-left: 1.5px solid #fff;
|
||||
border-bottom: 1.5px solid #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.v4 .remember-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.v4 .btn {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--red);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.v4 .btn:hover { background: var(--red); color: #000; }
|
||||
.v4 .disclaimer {
|
||||
margin-top: 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
text-align: center;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.v4 .disclaimer .red { color: var(--red); }
|
||||
|
||||
.picker-footer {
|
||||
min-width: 2240px;
|
||||
padding: 32px 72px 64px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.picker-footer .red { color: var(--red); }
|
||||
.picker-footer .legend { display: flex; gap: 40px; }
|
||||
.picker-footer .legend span strong { color: #ccc; }
|
||||
|
||||
.login-page input { caret-color: transparent; pointer-events: none; }
|
||||
.login-page button { pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="picker-header">
|
||||
<h1 class="picker-title">LOGIN PICKER R3</h1>
|
||||
<div class="picker-sub">
|
||||
ARRFLIX <span class="sep">/</span> 4 VARIANTS <span class="sep">/</span> ALL SHARE: TOP-BAR CENTERED LOGO + CENTERED SIGN IN
|
||||
</div>
|
||||
<div class="picker-meta">
|
||||
<span>VIEWPORT <strong>1920 × 1080</strong></span>
|
||||
<span>SCALE <strong>50%</strong></span>
|
||||
<span>SERVED <strong>127.0.0.1:8888 ONLY</strong></span>
|
||||
<span>ASSETS <strong>REAL LOGO + REAL BG</strong></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
|
||||
<!-- ================== VARIANT 1 ================== -->
|
||||
<section class="variant">
|
||||
<div class="variant-label">
|
||||
<div class="variant-num">01</div>
|
||||
<div class="variant-name">The Theater</div>
|
||||
<div class="variant-tag">Classic Card</div>
|
||||
</div>
|
||||
<div class="viewport">
|
||||
<div class="login-page v1">
|
||||
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
|
||||
<div class="stage">
|
||||
<div class="card">
|
||||
<h1>Sign In</h1>
|
||||
<div class="field"><label>User</label><input type="text"></div>
|
||||
<div class="field"><label>Password</label><input type="password"></div>
|
||||
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember me</span></div>
|
||||
<button class="btn">Sign In</button>
|
||||
<div class="disclaimer">Private invite only</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="variant-desc">
|
||||
<strong>Classic dark card.</strong> Top bar with centered ARRFLIX logo on
|
||||
gradient fade. Centered "Sign In" heading. Rectangular dark glass card,
|
||||
underline inputs, square red Sign In. Most familiar / Netflix-like.
|
||||
<span class="red">Safe pick.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ================== VARIANT 2 ================== -->
|
||||
<section class="variant">
|
||||
<div class="variant-label">
|
||||
<div class="variant-num">02</div>
|
||||
<div class="variant-name">The Marquee</div>
|
||||
<div class="variant-tag">Neon Outline</div>
|
||||
</div>
|
||||
<div class="viewport">
|
||||
<div class="login-page v2">
|
||||
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
|
||||
<div class="stage">
|
||||
<div class="marquee">
|
||||
<span class="corner tl"></span><span class="corner tr"></span>
|
||||
<span class="corner bl"></span><span class="corner br"></span>
|
||||
<div class="bulbs"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<h1>Sign In</h1>
|
||||
<div class="sub">Now Showing</div>
|
||||
<div class="field"><label>User</label><input type="text"></div>
|
||||
<div class="field"><label>Password</label><input type="password"></div>
|
||||
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember me</span></div>
|
||||
<button class="btn">Sign In</button>
|
||||
<div class="disclaimer">Private invite only</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="variant-desc">
|
||||
<strong>Neon-red outlined card with marquee bulbs.</strong> 2px red border
|
||||
with multi-layer glow (32px + 80px), L-bracket corner accents at 4 corners,
|
||||
row of red dots like theater marquee bulbs above. "NOW SHOWING" subtitle in
|
||||
red mono caps. Inputs have red borders. Most theatrical / signage feel.
|
||||
<span class="red">Stands out from the dark-card crowd.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ================== VARIANT 3 ================== -->
|
||||
<section class="variant">
|
||||
<div class="variant-label">
|
||||
<div class="variant-num">03</div>
|
||||
<div class="variant-name">The Cinema</div>
|
||||
<div class="variant-tag">Letterbox Wide</div>
|
||||
</div>
|
||||
<div class="viewport">
|
||||
<div class="login-page v3">
|
||||
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
|
||||
<div class="stage">
|
||||
<div class="wide-card">
|
||||
<h1>Sign In</h1>
|
||||
<div class="divider"></div>
|
||||
<div class="two-cols">
|
||||
<div class="field"><label>User</label><input type="text"></div>
|
||||
<div class="field"><label>Password</label><input type="password"></div>
|
||||
</div>
|
||||
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember me</span></div>
|
||||
<button class="btn">Sign In</button>
|
||||
<div class="disclaimer">Private invite only</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="variant-desc">
|
||||
<strong>2.35:1 cinematic letterbox.</strong> Top + bottom black bars frame
|
||||
the visible poster strip. Wide thin form card sits in the lit band — User
|
||||
and Password side-by-side, centered short Sign In button. Red hairline
|
||||
top + bottom borders on card give it a marquee feel.
|
||||
<span class="red">Most unique layout.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ================== VARIANT 4 ================== -->
|
||||
<section class="variant">
|
||||
<div class="variant-label">
|
||||
<div class="variant-num">04</div>
|
||||
<div class="variant-name">The Noir</div>
|
||||
<div class="variant-tag">Tight Mono Card</div>
|
||||
</div>
|
||||
<div class="viewport">
|
||||
<div class="login-page v4">
|
||||
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
|
||||
<div class="stage">
|
||||
<div class="tight-card">
|
||||
<h1>Sign In</h1>
|
||||
<div class="sub">Private · Invite · Only</div>
|
||||
<div class="field"><label>User <span class="num">01</span></label><input type="text"></div>
|
||||
<div class="field"><label>Password <span class="num">02</span></label><input type="password"></div>
|
||||
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember</span></div>
|
||||
<button class="btn">Sign In</button>
|
||||
<div class="disclaimer">Private invite only</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="variant-desc">
|
||||
<strong>Dimmed noir, tight mono card.</strong> Backdrop is grayscale-blurred
|
||||
and 92%-dark for a moody movie-still feel. Narrow 440px card with red top
|
||||
border + a centered "ARR · MMXXVI" badge on the rim, mono numbered field
|
||||
labels, outlined-then-filled Sign In button.
|
||||
<span class="red">Most editorial / boutique.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="picker-footer">
|
||||
<div>SERVED VIA <span class="red">HTTP://LOCALHOST:8888/PICKER.HTML</span> · 127.0.0.1 ONLY</div>
|
||||
<div class="legend">
|
||||
<span>PICK ONE <strong>01 / 02 / 03 / 04</strong></span>
|
||||
<span>NEXT <strong>PORT INTO TV.S8N.RU</strong></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
web-overrides/theater-design/poster-bg.jpg
Normal file
BIN
web-overrides/theater-design/poster-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
147
web-overrides/theater-design/theater-fullsize.html
Normal file
147
web-overrides/theater-design/theater-fullsize.html
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ARRFLIX Login — The Theater (fullsize target)</title>
|
||||
<style>
|
||||
:root {
|
||||
--red: #E50914;
|
||||
--font-display: "Bebas Neue", "Anton", Impact, "Haettenschweiler", "Liberation Sans Narrow", "Arial Narrow Bold", "Helvetica Neue Condensed Black", "DejaVu Sans Condensed", sans-serif;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, "Liberation Sans", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", "Monaco", "Cascadia Code", "JetBrains Mono", "Roboto Mono", "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: var(--font-body);
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.login-page.v1 {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
background: url("poster-bg.jpg") center / cover no-repeat, #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v1::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 60% at center,
|
||||
rgba(0,0,0,0) 0%,
|
||||
rgba(0,0,0,0.55) 55%,
|
||||
rgba(0,0,0,0.92) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 25%, rgba(0,0,0,0) 75%, rgba(0,0,0,0.7) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v1 .topbar {
|
||||
position: relative; z-index: 3;
|
||||
height: 88px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%);
|
||||
}
|
||||
.v1 .topbar img { width: 156px; height: auto; display: block; }
|
||||
.v1 .stage {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: calc(100% - 88px);
|
||||
}
|
||||
.v1 .card {
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
width: 540px;
|
||||
padding: 56px 56px 44px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.v1 .card h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 48px;
|
||||
color: #fff;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 36px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
.v1 .field { margin-bottom: 24px; }
|
||||
.v1 .field label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.v1 .field input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.18);
|
||||
padding: 12px 0 14px;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.v1 .row {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.v1 .check {
|
||||
width: 20px; height: 20px;
|
||||
background: var(--red);
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.v1 .check::after {
|
||||
content: ""; position: absolute;
|
||||
top: 5px; left: 4px; width: 11px; height: 5px;
|
||||
border-left: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.v1 .remember-label { font-size: 14px; color: #ccc; }
|
||||
.v1 .btn {
|
||||
width: 100%;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
padding: 18px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.v1 .disclaimer {
|
||||
margin-top: 28px;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
input { caret-color: transparent; pointer-events: none; }
|
||||
button { pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-page v1">
|
||||
<div class="topbar"><img src="arrflix-logo.png" alt="ARRFLIX"></div>
|
||||
<div class="stage">
|
||||
<div class="card">
|
||||
<h1>Sign In</h1>
|
||||
<div class="field"><label>User</label><input type="text"></div>
|
||||
<div class="field"><label>Password</label><input type="password"></div>
|
||||
<div class="row"><div class="check"></div><span class="remember-label">Remember me</span></div>
|
||||
<button class="btn">Sign In</button>
|
||||
<div class="disclaimer">Private invite only</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
268
web-overrides/theater-design/tv_theater_port.py
Normal file
268
web-overrides/theater-design/tv_theater_port.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Port "The Theater" login design (variant 01 from picker R3) to
|
||||
tv.s8n.ru jellyfin-stock.
|
||||
|
||||
Edits:
|
||||
1. /opt/docker/jellyfin-stock/web-overrides/index.html (precss)
|
||||
2. /home/docker/jellyfin-stock/config/config/branding.xml (CustomCss)
|
||||
|
||||
Theater spec:
|
||||
- Backdrop: Cineplex poster-bg.jpg with vignette + linear shadow
|
||||
- Top bar: 88px tall, transparent gradient fade, ARRFLIX logo
|
||||
CENTERED (156px wide) via .skinHeader::before
|
||||
- Hide .pageTitleWithLogo top-left (we paint centered instead)
|
||||
- "Sign In" heading: keep stock h1.sectionTitle "Please sign in",
|
||||
re-style large + centered (the visible text stays "Please sign in"
|
||||
-- short of bundle JS rewrite that's the JF i18n string; matches
|
||||
arrflix.s8n.ru behavior)
|
||||
- Form card: .padded-left.padded-right.padded-bottom-page styled
|
||||
as 540px wide dark glass card, padding, border, blur
|
||||
- Inputs: underline-only, red focus
|
||||
- Sign In: full-width red square button, 4px radius
|
||||
- Remember Me: red square checkbox (already wired in earlier round)
|
||||
- Disclaimer: small white-50% centered
|
||||
"""
|
||||
import re, sys
|
||||
|
||||
THEATER_PRECSS = """
|
||||
/* === ARRFLIX 2026-05-12 -- THE THEATER login port ====================
|
||||
Applied AFTER previous "ARRFLIX login-parity" + selectserver-extend
|
||||
+ hide-visualform + polish-flash blocks. !important + late cascade
|
||||
beats earlier rules. Sources:
|
||||
picker variant 01 at /tmp/picker_assets/picker.html (.v1.*)
|
||||
User pick 2026-05-12: "the theater sign in looks the best".
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
/* Top bar: 88px tall transparent fade. ARRFLIX wordmark centered. */
|
||||
.skinHeader,
|
||||
.skinHeader.semiTransparent,
|
||||
.skinHeader.focuscontainer-x,
|
||||
.skinHeader.focuscontainer-x.skinHeader-withBackground,
|
||||
.skinHeader.focuscontainer-x.skinHeader-withBackground.skinHeader-blurred {
|
||||
height: 88px !important;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%) !important;
|
||||
border: none !important;
|
||||
contain: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
/* Centered wordmark in top bar -- override the earlier
|
||||
.skinHeader::before { bg: none } from selectserver-extend block. */
|
||||
.skinHeader::before {
|
||||
content: "" !important;
|
||||
position: fixed !important;
|
||||
top: 16px !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
width: 156px !important;
|
||||
height: 56px !important;
|
||||
background-image: var(--arrflix-logo) !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: center !important;
|
||||
background-size: contain !important;
|
||||
z-index: 1001 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
/* Kill the top-left .pageTitleWithLogo entirely -- centered
|
||||
::before above replaces it. */
|
||||
.pageTitleWithLogo,
|
||||
.pageTitleWithDefaultLogo,
|
||||
h3.pageTitle.pageTitleWithLogo,
|
||||
h3.pageTitle.pageTitleWithDefaultLogo {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* #loginPage backdrop already handled by earlier #loginPage block
|
||||
(poster-bg + vignette). Add a stronger vignette on top so the
|
||||
card has more contrast. */
|
||||
#loginPage::before {
|
||||
content: "" !important;
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 60% at center,
|
||||
rgba(0,0,0,0) 0%,
|
||||
rgba(0,0,0,0.55) 55%,
|
||||
rgba(0,0,0,0.92) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 25%,
|
||||
rgba(0,0,0,0) 75%, rgba(0,0,0,0.7) 100%) !important;
|
||||
pointer-events: none !important;
|
||||
z-index: 0 !important;
|
||||
/* override the earlier blur+filter from cineplex.css #loginPage::before */
|
||||
filter: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* UN-hide the "Please sign in" heading (was hidden by hide-visualform
|
||||
block). Re-style as Theater Sign In heading -- big, white, centered. */
|
||||
#loginPage h1.sectionTitle,
|
||||
#loginPage .formDialogHeader,
|
||||
#loginPage .padded-left.padded-right.padded-bottom-page > h1.sectionTitle {
|
||||
display: block !important;
|
||||
font-family: "Bebas Neue", "Anton", Impact, "Haettenschweiler",
|
||||
"Liberation Sans Narrow", "Arial Narrow Bold", sans-serif !important;
|
||||
font-size: 48px !important;
|
||||
color: #fff !important;
|
||||
letter-spacing: 0.03em !important;
|
||||
margin: 0 0 32px !important;
|
||||
padding: 0 !important;
|
||||
line-height: 1 !important;
|
||||
text-align: center !important;
|
||||
text-transform: none !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
/* Form card: style the padded-left wrapper as a 540px-wide centered
|
||||
dark-glass card. Override hide-visualform's flex layout to keep
|
||||
center-vertical positioning. */
|
||||
#loginPage .padded-left.padded-right.padded-bottom-page {
|
||||
max-width: 540px !important;
|
||||
width: 540px !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 56px 56px 44px !important;
|
||||
background: rgba(0, 0, 0, 0.78) !important;
|
||||
border: 1px solid rgba(255,255,255,0.06) !important;
|
||||
border-radius: 0 !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
-webkit-backdrop-filter: blur(8px) !important;
|
||||
min-height: 0 !important;
|
||||
align-items: stretch !important;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.6) !important;
|
||||
}
|
||||
/* Vertical center the card on the page (account for 88px top bar). */
|
||||
#loginPage {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding-top: 88px !important;
|
||||
}
|
||||
|
||||
/* Form-internal: manualLoginForm + visualLoginForm full width inside card. */
|
||||
#loginPage .manualLoginForm,
|
||||
#loginPage form.manualLoginForm {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Inputs: underline-only, transparent bg, red focus. */
|
||||
#loginPage .inputContainer { margin-bottom: 24px !important; }
|
||||
#loginPage .inputContainer label,
|
||||
#loginPage .inputLabelFocused,
|
||||
#loginPage label.inputLabel {
|
||||
font-family: "SF Mono", "Monaco", "Cascadia Code", "JetBrains Mono",
|
||||
"Roboto Mono", "DejaVu Sans Mono", "Liberation Mono",
|
||||
Consolas, monospace !important;
|
||||
font-size: 11px !important;
|
||||
color: rgba(255,255,255,0.55) !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.22em !important;
|
||||
margin-bottom: 10px !important;
|
||||
font-weight: 600 !important;
|
||||
display: block !important;
|
||||
}
|
||||
#loginPage .emby-input {
|
||||
width: 100% !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.18) !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 12px 0 14px !important;
|
||||
font-size: 18px !important;
|
||||
color: #fff !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
#loginPage .emby-input:focus,
|
||||
#loginPage .inputContainer.focused .emby-input {
|
||||
border-bottom-color: #E50914 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Remember Me row centered. (Checkbox red already from earlier polish.) */
|
||||
#loginPage .checkboxContainer,
|
||||
#loginPage label.emby-checkbox-label {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
margin: 28px 0 30px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Sign In button: full-width red, 4px radius, square label. */
|
||||
#loginPage .raised.button-submit,
|
||||
#loginPage button.raised.button-submit,
|
||||
#loginPage .raised.block.emby-button[type="submit"] {
|
||||
width: 100% !important;
|
||||
background: #E50914 !important;
|
||||
color: #fff !important;
|
||||
font-size: 17px !important;
|
||||
font-weight: 700 !important;
|
||||
padding: 18px !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
text-transform: none !important;
|
||||
margin: 0 0 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Disclaimer: centered, light grey, small. */
|
||||
#loginPage .loginDisclaimer,
|
||||
#loginPage .disclaimerContainer,
|
||||
#loginPage .loginDisclaimerContainer {
|
||||
margin-top: 28px !important;
|
||||
font-size: 12px !important;
|
||||
color: rgba(255,255,255,0.5) !important;
|
||||
text-align: center !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
width: 100% !important;
|
||||
display: block !important;
|
||||
}
|
||||
"""
|
||||
|
||||
# Branding mirror: same selectors + rules, copied. Doesn't include the
|
||||
# var(--arrflix-logo) reference issue since branding.xml already
|
||||
# defines --arrflix-logo at L64.
|
||||
THEATER_BRANDING = THEATER_PRECSS # identical CSS body
|
||||
|
||||
|
||||
def patch_precss(path, marker, block):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
s = f.read()
|
||||
if marker in s:
|
||||
print(f" {path}: already patched")
|
||||
return
|
||||
m = re.search(r'(<style id="arrflix-precss">.*?)(</style>)', s, flags=re.DOTALL)
|
||||
if not m:
|
||||
sys.exit(f"ERROR: arrflix-precss block not found in {path}")
|
||||
s = s[:m.end(1)] + block + s[m.end(1):]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(s)
|
||||
print(f" {path}: patched (+{len(block)})")
|
||||
|
||||
|
||||
def patch_branding(path, marker, block):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
s = f.read()
|
||||
if marker in s:
|
||||
print(f" {path}: already patched")
|
||||
return
|
||||
CLOSE = "</CustomCss>"
|
||||
if CLOSE not in s:
|
||||
sys.exit(f"ERROR: </CustomCss> not found in {path}")
|
||||
s = s.replace(CLOSE, block + CLOSE, 1)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(s)
|
||||
print(f" {path}: patched (+{len(block)})")
|
||||
|
||||
|
||||
MARKER = "THE THEATER login port"
|
||||
|
||||
patch_precss(
|
||||
"/opt/docker/jellyfin-stock/web-overrides/index.html",
|
||||
MARKER, THEATER_PRECSS)
|
||||
|
||||
patch_branding(
|
||||
"/home/docker/jellyfin-stock/config/config/branding.xml",
|
||||
MARKER, THEATER_BRANDING)
|
||||
Loading…
Reference in a new issue