2026-07-22 16:23:05 +00:00
|
|
|
<!doctype html>
|
|
|
|
|
<html lang="cs">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
|
|
|
<meta name="robots" content="noindex">
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<title>Amber — můj účet</title>
|
2026-07-22 16:23:05 +00:00
|
|
|
<style>
|
|
|
|
|
:root { color-scheme: dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
|
|
|
|
|
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
--line:#2a2d36; --sunk:#141620; }
|
2026-07-22 16:23:05 +00:00
|
|
|
* { box-sizing:border-box; }
|
|
|
|
|
body { margin:0; background:var(--bg); color:var(--fg);
|
|
|
|
|
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
min-height:100dvh; padding:20px 16px 48px; }
|
|
|
|
|
.wrap { width:100%; max-width:560px; margin:0 auto; }
|
|
|
|
|
.card { background:var(--card); border-radius:16px; padding:22px 20px;
|
|
|
|
|
border:1px solid var(--line); box-shadow:0 10px 40px rgba(0,0,0,.25);
|
|
|
|
|
margin-bottom:16px; }
|
|
|
|
|
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
|
|
|
|
|
h2 { font-size:16px; margin:0 0 12px; color:var(--fg); }
|
|
|
|
|
p.sub { margin:0 0 16px; color:var(--muted); font-size:14px; line-height:1.5; }
|
|
|
|
|
label { display:block; font-size:13px; color:var(--muted); margin:14px 0 5px; }
|
|
|
|
|
input[type=email],input[type=password],input[type=text],input[type=number],select {
|
|
|
|
|
width:100%; padding:12px; border-radius:10px; border:1px solid var(--line);
|
2026-07-22 16:23:05 +00:00
|
|
|
background:var(--bg); color:var(--fg); font-size:15px; }
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
input:focus, select:focus { outline:2px solid var(--amber); border-color:transparent; }
|
|
|
|
|
.row { display:flex; gap:10px; } .row > * { flex:1; min-width:0; }
|
|
|
|
|
.hint { font-size:12px; color:var(--muted); margin:5px 0 0; line-height:1.45; }
|
|
|
|
|
.check { display:flex; gap:10px; align-items:flex-start; margin-top:14px;
|
|
|
|
|
font-size:14px; line-height:1.45; }
|
|
|
|
|
.check input { margin-top:3px; width:auto; }
|
|
|
|
|
button.btn { width:100%; margin-top:18px; padding:13px; border:none;
|
|
|
|
|
border-radius:10px; font-size:15px; font-weight:700; cursor:pointer; }
|
|
|
|
|
.primary { background:var(--amber); color:#1b1206; }
|
|
|
|
|
.ghost { background:transparent; color:var(--fg); border:1px solid var(--line); }
|
|
|
|
|
.danger { background:transparent; color:var(--err); border:1px solid var(--err); }
|
|
|
|
|
button:disabled { opacity:.5; cursor:default; }
|
|
|
|
|
button.link { background:none; border:none; color:var(--muted); font-size:13px;
|
|
|
|
|
cursor:pointer; text-decoration:underline; padding:0; margin-top:12px; }
|
|
|
|
|
.msg { margin-top:12px; font-size:14px; min-height:18px; line-height:1.45; }
|
|
|
|
|
.msg.err { color:var(--err); } .msg.ok { color:var(--ok); }
|
|
|
|
|
.hidden { display:none !important; }
|
|
|
|
|
.spin { display:inline-block; width:15px; height:15px; border:2px solid #1b1206;
|
|
|
|
|
border-top-color:transparent; border-radius:50%; animation:s .7s linear infinite;
|
|
|
|
|
vertical-align:-2px; margin-right:7px; }
|
|
|
|
|
@keyframes s { to { transform:rotate(360deg); } }
|
|
|
|
|
nav { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:16px; }
|
|
|
|
|
nav button { padding:9px 13px; border-radius:999px; border:1px solid var(--line);
|
|
|
|
|
background:var(--card); color:var(--muted); font-size:13.5px; cursor:pointer; }
|
|
|
|
|
nav button[aria-current="true"] { background:var(--amber); color:#1b1206;
|
|
|
|
|
border-color:transparent; font-weight:700; }
|
|
|
|
|
.who { display:flex; justify-content:space-between; align-items:center;
|
|
|
|
|
font-size:13px; color:var(--muted); margin-bottom:14px; gap:12px; }
|
|
|
|
|
.who b { color:var(--fg); }
|
|
|
|
|
.sep { height:1px; background:var(--line); margin:18px 0 2px; border:0; }
|
|
|
|
|
.list { list-style:none; padding:0; margin:0; }
|
|
|
|
|
.list li { padding:12px; border:1px solid var(--line); border-radius:10px;
|
|
|
|
|
margin-bottom:8px; background:var(--sunk); font-size:14px; }
|
|
|
|
|
.list .meta { color:var(--muted); font-size:12.5px; margin-top:3px; }
|
|
|
|
|
.rowbtn { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
|
|
|
|
.rowbtn button { margin:0; width:auto; padding:8px 12px; font-size:13px; }
|
|
|
|
|
code.url { background:var(--sunk); padding:3px 7px; border-radius:6px;
|
|
|
|
|
font-size:13px; word-break:break-all; }
|
|
|
|
|
.warn { border-left:3px solid var(--amber); padding-left:12px; margin:14px 0;
|
|
|
|
|
font-size:13px; color:var(--muted); line-height:1.5; }
|
|
|
|
|
ol.steps { padding-left:20px; margin:0; font-size:14px; line-height:1.7; }
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
/* setup flow */
|
|
|
|
|
.step { border:1px solid var(--line); border-radius:12px; padding:14px;
|
|
|
|
|
margin-bottom:10px; background:var(--sunk); }
|
|
|
|
|
.step.done { opacity:.6; }
|
|
|
|
|
/* A finished step is dimmed, but a button inside one is still live. Dimming it
|
|
|
|
|
with the rest made "Změnit výběr" read as disabled, which is the one thing
|
|
|
|
|
this app has repeatedly got wrong. */
|
|
|
|
|
.step.done .btn, .step.done button.link { opacity:1; }
|
|
|
|
|
.step.now { border-color:var(--amber); }
|
|
|
|
|
.step h3 { font-size:14.5px; margin:0; display:flex; gap:9px;
|
|
|
|
|
align-items:center; font-weight:700; }
|
|
|
|
|
.step .num { display:inline-flex; align-items:center; justify-content:center;
|
|
|
|
|
width:22px; height:22px; flex:0 0 22px; border-radius:50%; font-size:12px;
|
|
|
|
|
background:var(--line); color:var(--muted); }
|
|
|
|
|
.step.done .num { background:var(--ok); color:#08210f; }
|
|
|
|
|
.step.now .num { background:var(--amber); color:#1b1206; }
|
|
|
|
|
.step .body { margin-top:10px; }
|
|
|
|
|
.step .body p { margin:0 0 8px; font-size:13.5px; color:var(--muted);
|
|
|
|
|
line-height:1.55; }
|
|
|
|
|
.step .btn { margin-top:12px; }
|
|
|
|
|
.prov { border:1px solid var(--line); border-radius:10px; padding:12px;
|
|
|
|
|
margin-bottom:8px; background:var(--card); }
|
|
|
|
|
.prov.on { border-color:var(--amber); }
|
|
|
|
|
.prov .top { display:flex; justify-content:space-between; gap:10px;
|
|
|
|
|
align-items:baseline; }
|
|
|
|
|
.prov .price { font-weight:700; white-space:nowrap; }
|
|
|
|
|
.prov ul { margin:8px 0 0; padding-left:17px; font-size:12.5px;
|
|
|
|
|
color:var(--muted); line-height:1.55; }
|
|
|
|
|
.prov ul.cons { margin-top:10px; }
|
|
|
|
|
.prov ul.cons li::marker { color:var(--err); }
|
|
|
|
|
.badge { display:inline-block; font-size:11px; font-weight:700; padding:2px 7px;
|
|
|
|
|
border-radius:999px; background:var(--amber); color:#1b1206; margin-left:6px;
|
|
|
|
|
vertical-align:1px; }
|
|
|
|
|
.badge.soft { background:var(--line); color:var(--fg); }
|
|
|
|
|
.guide { margin:0; padding-left:19px; font-size:13px; line-height:1.6; }
|
|
|
|
|
.guide li { margin-bottom:5px; }
|
2026-07-22 16:23:05 +00:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="wrap">
|
|
|
|
|
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<!-- ── signed out ───────────────────────────────────────────────────────── -->
|
|
|
|
|
<div id="anon">
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h1>Amber</h1>
|
|
|
|
|
<p class="sub">Přihlas se a spravuj svůj účet, profily a nastavení.
|
|
|
|
|
Přihlášení zůstane uložené, takže příště to bude rovnou tady.</p>
|
|
|
|
|
<label for="email">E-mail</label>
|
|
|
|
|
<input id="email" type="email" autocomplete="username" inputmode="email">
|
|
|
|
|
<label for="pass">Heslo</label>
|
|
|
|
|
<input id="pass" type="password" autocomplete="current-password">
|
|
|
|
|
<button id="loginBtn" class="btn primary">Přihlásit se</button>
|
|
|
|
|
<div id="anonMsg" class="msg"></div>
|
|
|
|
|
<p class="hint">Účty zakládá Richard — registrace tu není. Když se nemůžeš
|
|
|
|
|
dostat dovnitř, napiš mu.</p>
|
|
|
|
|
</div>
|
2026-07-22 16:23:05 +00:00
|
|
|
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<div class="card">
|
|
|
|
|
<h2>Nainstalovat na televizi</h2>
|
|
|
|
|
<ol class="steps">
|
2026-08-07 07:33:28 +00:00
|
|
|
<li>Na televizi otevři <b>prohlížeč</b> a zadej
|
|
|
|
|
<code class="url" id="tvUrl">amber.petruzalekr.cz</code>.</li>
|
|
|
|
|
<li>Zmáčkni tlačítko níž — stáhne se instalace, pak ji spusť.</li>
|
|
|
|
|
<li>V Amberu zvol <b>Přihlásit z jiného zařízení</b> a načti QR kód
|
|
|
|
|
telefonem. Na televizi tak nemusíš nic psát.</li>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
</ol>
|
2026-08-07 07:33:28 +00:00
|
|
|
<a class="btn primary" id="tvDl" href="/tv"
|
|
|
|
|
style="display:block;text-align:center;text-decoration:none">Stáhnout na televizi</a>
|
|
|
|
|
<p class="hint">Stahuje se aplikace pro Android TV. Bez účtu se do ní
|
|
|
|
|
nepřihlásíš, takže samotný soubor nikomu nic nedá.</p>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
</div>
|
2026-07-22 16:23:05 +00:00
|
|
|
</div>
|
|
|
|
|
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<!-- ── signed in ────────────────────────────────────────────────────────── -->
|
|
|
|
|
<div id="app" class="hidden">
|
|
|
|
|
<div class="who">
|
|
|
|
|
<span>Účet: <b id="whoEmail"></b></span>
|
|
|
|
|
<button id="logoutBtn" class="link" style="margin:0">Odhlásit</button>
|
2026-07-22 16:23:05 +00:00
|
|
|
</div>
|
|
|
|
|
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
<!-- Resume path for anyone who chose "nastavím to později". Without this,
|
|
|
|
|
skipping is indistinguishable from finishing and the flow is gone for
|
|
|
|
|
good. -->
|
|
|
|
|
<div id="stBanner" class="warn hidden">Nastavení ještě není dokončené.
|
|
|
|
|
Amber zatím nemá kde hledat.
|
|
|
|
|
<button id="stResume" class="link">Pokračovat v nastavení</button></div>
|
|
|
|
|
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<nav id="nav"></nav>
|
|
|
|
|
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
<!-- setup flow: the BYOC path, for someone who brings their own accounts -->
|
|
|
|
|
<section id="tab-start" class="card tab hidden">
|
|
|
|
|
<h2>Začínáme</h2>
|
|
|
|
|
<p class="sub">Amber sám nic nehostuje, potřebuje účet aspoň u jedné
|
|
|
|
|
služby. Projdi to po krocích. Můžeš kdykoli odejít a vrátit se, tvůj
|
|
|
|
|
postup se ukládá k účtu, takže na něj dosáhneš i z jiného zařízení.</p>
|
|
|
|
|
<div id="stList"></div>
|
|
|
|
|
<div id="stMsg" class="msg"></div>
|
|
|
|
|
<button id="stLater" class="link">Nastavím to později</button>
|
|
|
|
|
</section>
|
|
|
|
|
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<!-- profiles -->
|
|
|
|
|
<section id="tab-profiles" class="card tab hidden">
|
|
|
|
|
<h2>Profily</h2>
|
|
|
|
|
<p class="sub">Každý profil má vlastní seznamy, rozkoukané a nastavení
|
|
|
|
|
přehrávání.</p>
|
|
|
|
|
<ul class="list" id="profileList"></ul>
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<label for="pfName">Upravit profil</label>
|
|
|
|
|
<select id="pfSelect"></select>
|
|
|
|
|
<label for="pfName2">Jméno</label>
|
|
|
|
|
<input id="pfName2" type="text" maxlength="40">
|
|
|
|
|
<label for="pfRating">Věkový strop</label>
|
|
|
|
|
<select id="pfRating">
|
|
|
|
|
<option value="">Podle účtu</option>
|
|
|
|
|
<option value="kids">Děti (do 7)</option>
|
|
|
|
|
<option value="twelve">12+</option>
|
|
|
|
|
<option value="fifteen">15+</option>
|
|
|
|
|
<option value="unrestricted">Bez omezení</option>
|
|
|
|
|
</select>
|
|
|
|
|
<div class="check">
|
|
|
|
|
<input id="pfChild" type="checkbox">
|
2026-08-12 20:04:36 +00:00
|
|
|
<!-- Text is set in enter(): an account without 18+ enabled must not be told
|
|
|
|
|
that 18+ exists. See there. -->
|
|
|
|
|
<label for="pfChild" id="pfChildLabel" style="margin:0">Dětský profil</label>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
</div>
|
|
|
|
|
<label for="pfPin">Nový PIN (4 číslice, nech prázdné = beze změny)</label>
|
|
|
|
|
<input id="pfPin" type="text" inputmode="numeric" maxlength="4" autocomplete="off">
|
|
|
|
|
<button id="pfSave" class="btn primary">Uložit profil</button>
|
|
|
|
|
<div id="pfMsg" class="msg"></div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- playback prefs -->
|
|
|
|
|
<section id="tab-playback" class="card tab hidden">
|
|
|
|
|
<h2>Přehrávání</h2>
|
|
|
|
|
<p class="sub">Nastavení jazyků platí pro vybraný profil a projeví se na
|
|
|
|
|
všech zařízeních.</p>
|
|
|
|
|
<label for="prProfile">Profil</label>
|
|
|
|
|
<select id="prProfile"></select>
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div><label for="prAudio">Zvuk — hlavní</label>
|
|
|
|
|
<select id="prAudio" class="lang"></select></div>
|
|
|
|
|
<div><label for="prAudio2">Zvuk — záložní</label>
|
|
|
|
|
<select id="prAudio2" class="lang"></select></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div><label for="prSub">Titulky — hlavní</label>
|
|
|
|
|
<select id="prSub" class="lang sub"></select></div>
|
|
|
|
|
<div><label for="prSub2">Titulky — záložní</label>
|
|
|
|
|
<select id="prSub2" class="lang sub"></select></div>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="hint">Čeština jako hlavní zvuk znamená, že Amber vždycky hledá
|
|
|
|
|
ověřený český dabing. Záložní jazyk se použije jen tehdy, když žádný
|
|
|
|
|
český zdroj neexistuje.</p>
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div><label for="prAnimeAudio">Anime — zvuk</label>
|
|
|
|
|
<select id="prAnimeAudio" class="lang"></select></div>
|
|
|
|
|
<div><label for="prAnimeSub">Anime — titulky</label>
|
|
|
|
|
<select id="prAnimeSub" class="lang sub"></select></div>
|
|
|
|
|
</div>
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<label for="prRes">Preferované rozlišení</label>
|
|
|
|
|
<select id="prRes">
|
|
|
|
|
<option value="">Bez preference</option>
|
|
|
|
|
<option value="2160">4K (2160p)</option>
|
|
|
|
|
<option value="1080">1080p</option>
|
|
|
|
|
<option value="720">720p</option>
|
|
|
|
|
</select>
|
|
|
|
|
<div class="check">
|
|
|
|
|
<input id="prHighest" type="checkbox">
|
|
|
|
|
<label for="prHighest" style="margin:0">Řadit zdroje s vyšším rozlišením výš</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="check">
|
|
|
|
|
<input id="prSigns" type="checkbox">
|
|
|
|
|
<label for="prSigns" style="margin:0">Spojit titulky pro nápisy a dialogy</label>
|
|
|
|
|
</div>
|
|
|
|
|
<label for="prFeedback">Ptát se, jak se přehrávalo</label>
|
|
|
|
|
<select id="prFeedback">
|
|
|
|
|
<option value="off">Nikdy</option>
|
|
|
|
|
<option value="sometimes">Občas</option>
|
|
|
|
|
<option value="always">Po každém přehrání</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button id="prSave" class="btn primary">Uložit přehrávání</button>
|
|
|
|
|
<div id="prMsg" class="msg"></div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- addons -->
|
|
|
|
|
<section id="tab-addons" class="card tab hidden">
|
|
|
|
|
<h2>Zdroje</h2>
|
|
|
|
|
<p class="sub">Odkud Amber bere filmy a seriály. Uloženo zašifrovaně —
|
|
|
|
|
klíč se počítá z tvého hesla a nikdy neopustí prohlížeč.</p>
|
|
|
|
|
|
|
|
|
|
<div id="adNeedPass">
|
|
|
|
|
<label for="adPass">Heslo k účtu</label>
|
|
|
|
|
<input id="adPass" type="password" autocomplete="current-password">
|
|
|
|
|
<p class="hint">Bez hesla nelze zdroje rozšifrovat ani uložit.</p>
|
|
|
|
|
<button id="adUnlock" class="btn primary">Odemknout zdroje</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="adBody" class="hidden">
|
|
|
|
|
<div id="adSetup">
|
|
|
|
|
<p class="sub">Ještě tu nic není. Vyber, jak to nastavit:</p>
|
The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".
So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.
Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.
status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.
The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.
Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
|
|
|
<button id="adFamily" class="btn ghost">Mám kód od Richarda</button>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<button id="adByoc" class="btn ghost">Mám vlastní účty u služeb</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- family code path -->
|
|
|
|
|
<div id="adFamilyBox" class="hidden">
|
The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".
So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.
Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.
status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.
The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.
Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
|
|
|
<label for="adCode">Kód</label>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<input id="adCode" type="text" autocomplete="off" spellcheck="false">
|
The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".
So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.
Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.
status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.
The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.
Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
|
|
|
<p class="hint">Kód dostaneš od Richarda a nastaví ti zdroje, které s tebou
|
|
|
|
|
sdílí. Rodina a přátelé mají každý svůj vlastní kód, takže dostaneš
|
|
|
|
|
ty správné.</p>
|
|
|
|
|
<button id="adCodeGo" class="btn primary">Použít kód</button>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<button class="link adBack">Zpět</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- BYOC path -->
|
|
|
|
|
<div id="adByocBox" class="hidden">
|
|
|
|
|
<p class="sub">Amber si z tvých přihlašovacích údajů poskládá adresy
|
|
|
|
|
zdrojů sám. Údaje jdou přímo do doplňku, přes náš server neprojdou.</p>
|
|
|
|
|
|
2026-08-07 07:09:56 +00:00
|
|
|
<h2 style="margin-top:18px">prehraj.to <span style="color:var(--muted);font-weight:400">— nebo webshare níž</span></h2>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<div class="row">
|
|
|
|
|
<div><label for="byPtUser">Jméno</label>
|
|
|
|
|
<input id="byPtUser" type="text" autocomplete="off" spellcheck="false"></div>
|
|
|
|
|
<div><label for="byPtPass">Heslo</label>
|
|
|
|
|
<input id="byPtPass" type="password" autocomplete="off"></div>
|
|
|
|
|
</div>
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
<p class="hint">Vyplň prehraj.to, webshare, nebo obojí. Stačí jeden
|
|
|
|
|
z nich, obojí najde nejvíc. Bez českého hosta nebudou české dabingy.
|
|
|
|
|
Když chceš jen anime nebo originální znění, vyplň dole samotný
|
|
|
|
|
TorBox.</p>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
|
2026-08-07 07:09:56 +00:00
|
|
|
<h2 style="margin-top:18px">webshare.cz</h2>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<div class="row">
|
|
|
|
|
<div><label for="byWsUser">Jméno</label>
|
|
|
|
|
<input id="byWsUser" type="text" autocomplete="off" spellcheck="false"></div>
|
|
|
|
|
<div><label for="byWsPass">Heslo</label>
|
|
|
|
|
<input id="byWsPass" type="password" autocomplete="off"></div>
|
|
|
|
|
</div>
|
2026-08-07 07:09:56 +00:00
|
|
|
<p class="hint">Druhý český host, funguje i sám o sobě. U každého
|
|
|
|
|
hosta ale buď obojí, nebo nic — půlka údajů selže při každém hledání.</p>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
|
|
|
|
|
<h2 style="margin-top:18px">TorBox <span style="color:var(--muted);font-weight:400">— volitelné, ale doporučené</span></h2>
|
|
|
|
|
<label for="byTbKey">API klíč</label>
|
|
|
|
|
<input id="byTbKey" type="text" autocomplete="off" spellcheck="false"
|
|
|
|
|
placeholder="z torbox.app → Settings → API">
|
|
|
|
|
<p class="hint"><b>Tohle je zdroj s největším výběrem.</b> Pro cokoli,
|
|
|
|
|
co není česky dabované — původní znění, novinky, seriály, 4K — je
|
|
|
|
|
TorBox zdaleka nejlepší a najde toho nejvíc. Bez něj zůstaneš jen
|
|
|
|
|
u českých zdrojů.</p>
|
|
|
|
|
|
|
|
|
|
<label for="byTmdb">TMDB klíč</label>
|
|
|
|
|
<input id="byTmdb" type="text" autocomplete="off" spellcheck="false">
|
|
|
|
|
<p class="hint">Bez něj se nenačtou plakáty ani popisy. Zdarma na
|
|
|
|
|
themoviedb.org.</p>
|
|
|
|
|
|
|
|
|
|
<button id="byGo" class="btn primary">Nastavit zdroje</button>
|
|
|
|
|
<button class="link adBack">Zpět</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- manual edit of the current config -->
|
|
|
|
|
<div id="adEdit" class="hidden">
|
|
|
|
|
<label for="adTorbox">TorBox</label>
|
|
|
|
|
<input id="adTorbox" type="text" spellcheck="false" placeholder="https://…">
|
|
|
|
|
<label for="adCzech">Český zdroj</label>
|
|
|
|
|
<input id="adCzech" type="text" spellcheck="false" placeholder="https://…">
|
|
|
|
|
<label for="adTmdb">TMDB klíč</label>
|
|
|
|
|
<input id="adTmdb" type="text" spellcheck="false">
|
|
|
|
|
<div id="adAdultBox" class="hidden">
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<label for="adAdult">Zdroj 18+</label>
|
|
|
|
|
<input id="adAdult" type="text" spellcheck="false" placeholder="https://…">
|
|
|
|
|
</div>
|
|
|
|
|
<p class="hint">Prázdné pole zdroj z účtu odebere.</p>
|
|
|
|
|
<button id="adSave" class="btn primary">Uložit zdroje</button>
|
|
|
|
|
<button id="adRedo" class="link">Nastavit znovu od začátku</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="adMsg" class="msg"></div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- devices -->
|
|
|
|
|
<section id="tab-devices" class="card tab hidden">
|
|
|
|
|
<h2>Zařízení</h2>
|
|
|
|
|
<p class="sub">Přihlášené televize a počítače. Odebrání zabrání dalšímu
|
|
|
|
|
přihlašování z toho zařízení.</p>
|
|
|
|
|
<ul class="list" id="deviceList"></ul>
|
|
|
|
|
<div id="dvMsg" class="msg"></div>
|
|
|
|
|
<p class="hint">Už přihlášené zařízení může dobíhat, dokud mu nevyprší
|
|
|
|
|
token — odebrání zastaví nová přihlášení, ne běžící relaci.</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- downloads -->
|
|
|
|
|
<section id="tab-download" class="card tab hidden">
|
|
|
|
|
<h2>Stáhnout Amber</h2>
|
|
|
|
|
<p class="sub">Verze podle tvého účtu.</p>
|
|
|
|
|
<div id="dlList"></div>
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<h2 style="margin-top:16px">Televize</h2>
|
|
|
|
|
<ol class="steps">
|
2026-08-07 07:33:28 +00:00
|
|
|
<li>Na televizi otevři prohlížeč, zadej
|
|
|
|
|
<code class="url">amber.petruzalekr.cz</code> a zmáčkni
|
|
|
|
|
<b>Stáhnout na televizi</b>.</li>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<li>V Amberu zvol <b>Přihlásit z jiného zařízení</b> a načti QR telefonem.</li>
|
|
|
|
|
</ol>
|
2026-08-07 07:33:28 +00:00
|
|
|
<p class="hint">Kratší cesta, když se ti chce psát: rovnou
|
|
|
|
|
<code class="url">amber.petruzalekr.cz/tv</code> stáhne soubor bez klikání.</p>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
<div id="dlMsg" class="msg"></div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- account -->
|
|
|
|
|
<section id="tab-account" class="card tab hidden">
|
|
|
|
|
<h2>Účet</h2>
|
|
|
|
|
<label for="acEmail">E-mail</label>
|
|
|
|
|
<input id="acEmail" type="email" disabled>
|
|
|
|
|
<p class="hint">E-mail mění správce.</p>
|
|
|
|
|
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<label for="acRating">Výchozí věkový strop účtu</label>
|
|
|
|
|
<select id="acRating">
|
|
|
|
|
<option value="unrestricted">Bez omezení</option>
|
|
|
|
|
<option value="fifteen">15+</option>
|
|
|
|
|
<option value="twelve">12+</option>
|
|
|
|
|
<option value="kids">Děti (do 7)</option>
|
|
|
|
|
</select>
|
|
|
|
|
<p class="hint">Použije se pro profily, které nemají vlastní strop.</p>
|
|
|
|
|
<button id="acSaveRating" class="btn primary">Uložit</button>
|
|
|
|
|
|
|
|
|
|
<hr class="sep">
|
|
|
|
|
<h2 style="margin-top:18px">Změna hesla</h2>
|
|
|
|
|
<div class="warn">Heslo šifruje tvoje zdroje. Změna je proto přešifruje —
|
|
|
|
|
musíš mít v této relaci odemčené <b>Zdroje</b>, jinak o ně přijdeš.</div>
|
|
|
|
|
<label for="acOld">Současné heslo</label>
|
|
|
|
|
<input id="acOld" type="password" autocomplete="current-password">
|
|
|
|
|
<label for="acNew">Nové heslo</label>
|
|
|
|
|
<input id="acNew" type="password" autocomplete="new-password">
|
|
|
|
|
<label for="acNew2">Nové heslo znovu</label>
|
|
|
|
|
<input id="acNew2" type="password" autocomplete="new-password">
|
|
|
|
|
<button id="acChange" class="btn danger" disabled>Změnit heslo</button>
|
|
|
|
|
<div id="acMsg" class="msg"></div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
2026-07-22 16:23:05 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
// ── crypto: MUST match the app byte-for-byte ─────────────────────────────────
|
|
|
|
|
// addon_config_crypto.dart: key = PBKDF2-HMAC-SHA256(password, salt, 210000,
|
|
|
|
|
// 256 bit); blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] );
|
|
|
|
|
// kdf id "pbkdf2-sha256-210000". Lifted unchanged from settings.html, which is
|
|
|
|
|
// pinned against the Dart implementation by addon_config_crypto_interop_test.
|
|
|
|
|
var subtle = crypto.subtle;
|
|
|
|
|
var PBKDF2_ITERS = 210000;
|
|
|
|
|
var KDF_ID = "pbkdf2-sha256-" + PBKDF2_ITERS;
|
|
|
|
|
|
|
|
|
|
function b64e(u8){var s="";for(var i=0;i<u8.length;i++)s+=String.fromCharCode(u8[i]);return btoa(s);}
|
|
|
|
|
function b64d(s){var bin=atob(s);var u8=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)u8[i]=bin.charCodeAt(i);return u8;}
|
|
|
|
|
function b64url(str){return btoa(unescape(encodeURIComponent(str)))
|
|
|
|
|
.replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");}
|
|
|
|
|
function concat(){var n=0,i;for(i=0;i<arguments.length;i++)n+=arguments[i].length;
|
|
|
|
|
var out=new Uint8Array(n),o=0;for(i=0;i<arguments.length;i++){out.set(arguments[i],o);o+=arguments[i].length;}return out;}
|
|
|
|
|
|
|
|
|
|
async function deriveKey(password, salt){
|
|
|
|
|
var base=await subtle.importKey("raw",new TextEncoder().encode(password),"PBKDF2",false,["deriveBits"]);
|
|
|
|
|
var bits=await subtle.deriveBits({name:"PBKDF2",hash:"SHA-256",salt:salt,iterations:PBKDF2_ITERS},base,256);
|
|
|
|
|
return subtle.importKey("raw",bits,"AES-GCM",false,["encrypt","decrypt"]);
|
|
|
|
|
}
|
|
|
|
|
async function decryptBlob(blobB64, password, saltB64){
|
|
|
|
|
var key=await deriveKey(password, b64d(saltB64));
|
|
|
|
|
var packed=b64d(blobB64);
|
|
|
|
|
var clear=await subtle.decrypt(
|
|
|
|
|
{name:"AES-GCM",iv:packed.slice(0,12),tagLength:128}, key, packed.slice(12));
|
|
|
|
|
return new TextDecoder().decode(clear);
|
|
|
|
|
}
|
|
|
|
|
// Reuse the record's EXISTING salt so the key stays identical to the one the
|
|
|
|
|
// user's devices already cached — an already-signed-in TV then decrypts the
|
|
|
|
|
// edited blob without re-entering a password.
|
|
|
|
|
async function encryptWithSalt(plaintext, password, saltB64){
|
|
|
|
|
var key=await deriveKey(password, b64d(saltB64));
|
|
|
|
|
var nonce=crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
|
var ct=new Uint8Array(await subtle.encrypt(
|
|
|
|
|
{name:"AES-GCM",iv:nonce,tagLength:128},key,new TextEncoder().encode(plaintext)));
|
|
|
|
|
return b64e(concat(nonce, ct));
|
|
|
|
|
}
|
|
|
|
|
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
|
|
|
|
|
|
|
|
|
|
// ── addon hosts. Public, credential-free bases; the token segment carries the
|
|
|
|
|
// credentials and is minted per user. ───────────────────────────────────────
|
|
|
|
|
var PREHRAJTO_BASE = "https://prehrajto.petruzalekr.cz";
|
|
|
|
|
var TORBOX_BASE = "https://torbox.petruzalekr.cz";
|
|
|
|
|
|
|
|
|
|
// ── state ────────────────────────────────────────────────────────────────────
|
|
|
|
|
var TOKEN=null, USER=null, PASSWORD=null;
|
|
|
|
|
var PROFILES=[], CONFIGS=[], PREFS={};
|
|
|
|
|
var cfgRec=null, cfg=null; // active addon_config row + plaintext
|
|
|
|
|
var LS="amber.session";
|
|
|
|
|
|
|
|
|
|
function $(id){ return document.getElementById(id); }
|
|
|
|
|
function setMsg(el,t,cls){ el.className="msg "+(cls||""); el.textContent=t||""; }
|
|
|
|
|
function busy(btn,on,label){ if(!btn.dataset.label) btn.dataset.label=btn.textContent;
|
|
|
|
|
btn.disabled=on; btn.innerHTML = on ? '<span class="spin"></span>'+label : btn.dataset.label; }
|
|
|
|
|
|
|
|
|
|
async function api(method, path, body, opts){
|
|
|
|
|
var o={ method:method, headers:{} };
|
|
|
|
|
if (body!==undefined && body!==null){ o.headers["Content-Type"]="application/json";
|
|
|
|
|
o.body=JSON.stringify(body); }
|
|
|
|
|
if (TOKEN && !(opts&&opts.anon)) o.headers["Authorization"]=TOKEN;
|
|
|
|
|
var r=await fetch(path,o); var d=null; try{ d=await r.json(); }catch(_){}
|
|
|
|
|
if (r.status===401 && TOKEN){ signOut(); }
|
|
|
|
|
return { ok:r.ok, status:r.status, data:d };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── session ──────────────────────────────────────────────────────────────────
|
|
|
|
|
// The token persists; the password never does. Anything needing the password
|
|
|
|
|
// (the addon vault) asks for it in the moment — that is the honest split
|
|
|
|
|
// between "stay signed in" and "hold the key to your credentials in a browser".
|
|
|
|
|
function saveSession(){ try{ localStorage.setItem(LS, TOKEN||""); }catch(_){} }
|
|
|
|
|
function signOut(){
|
|
|
|
|
TOKEN=null; USER=null; PASSWORD=null; cfg=null; cfgRec=null;
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
// CONFIGS and PROVIDERS decide what the setup flow claims is finished, so
|
|
|
|
|
// leaving them behind would show the next person to sign in on this browser a
|
|
|
|
|
// checklist describing somebody else's account.
|
|
|
|
|
CONFIGS=[]; PROVIDERS=[];
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
try{ localStorage.removeItem(LS); }catch(_){}
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
$("stBanner").className="warn hidden";
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
$("app").className="hidden"; $("anon").className="";
|
|
|
|
|
}
|
|
|
|
|
async function restore(){
|
|
|
|
|
var t=null; try{ t=localStorage.getItem(LS); }catch(_){}
|
|
|
|
|
if (!t) return false;
|
|
|
|
|
TOKEN=t;
|
|
|
|
|
var r=await api("POST","/api/collections/users/auth-refresh",null);
|
|
|
|
|
if (!r.ok || !r.data || !r.data.token){ TOKEN=null; return false; }
|
|
|
|
|
TOKEN=r.data.token; USER=r.data.record; saveSession();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("loginBtn").onclick=async function(){
|
|
|
|
|
var email=$("email").value.trim(), pass=$("pass").value;
|
|
|
|
|
if(!email||!pass){ setMsg($("anonMsg"),"Vyplň e-mail i heslo.","err"); return; }
|
|
|
|
|
setMsg($("anonMsg"),""); busy($("loginBtn"),true,"Přihlašuji…");
|
|
|
|
|
var r=await api("POST","/api/collections/users/auth-with-password",
|
|
|
|
|
{ identity:email, password:pass }, {anon:true});
|
|
|
|
|
busy($("loginBtn"),false);
|
|
|
|
|
if(!r.ok){ setMsg($("anonMsg"), r.status===400
|
|
|
|
|
? "Špatný e-mail nebo heslo." : "Přihlášení selhalo.","err"); return; }
|
|
|
|
|
TOKEN=r.data.token; USER=r.data.record; PASSWORD=pass; saveSession();
|
|
|
|
|
await enter();
|
|
|
|
|
};
|
|
|
|
|
$("logoutBtn").onclick=signOut;
|
|
|
|
|
|
|
|
|
|
// ── shell ────────────────────────────────────────────────────────────────────
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
var TABS=[["start","Začínáme"],["profiles","Profily"],["playback","Přehrávání"],
|
|
|
|
|
["addons","Zdroje"],["devices","Zařízení"],["download","Stáhnout"],
|
|
|
|
|
["account","Účet"]];
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
function showTab(id){
|
|
|
|
|
TABS.forEach(function(t){
|
|
|
|
|
$("tab-"+t[0]).className = "card tab" + (t[0]===id ? "" : " hidden");
|
|
|
|
|
var b=$("nav-"+t[0]); if(b) b.setAttribute("aria-current", t[0]===id?"true":"false");
|
|
|
|
|
});
|
|
|
|
|
try{ location.hash=id; }catch(_){}
|
|
|
|
|
}
|
|
|
|
|
function buildNav(){
|
|
|
|
|
var n=$("nav"); n.innerHTML="";
|
|
|
|
|
TABS.forEach(function(t){
|
|
|
|
|
var b=document.createElement("button");
|
|
|
|
|
b.id="nav-"+t[0]; b.textContent=t[1];
|
|
|
|
|
b.onclick=function(){ showTab(t[0]); };
|
|
|
|
|
n.appendChild(b);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function enter(){
|
|
|
|
|
$("anon").className="hidden"; $("app").className="";
|
|
|
|
|
$("whoEmail").textContent=(USER&&USER.email)||"";
|
|
|
|
|
buildNav();
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
await Promise.all([loadProfiles(), loadDevices(), loadDownloads(),
|
|
|
|
|
loadProviders(), loadConfigList()]);
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
$("acEmail").value=(USER&&USER.email)||"";
|
|
|
|
|
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
|
2026-08-12 20:04:36 +00:00
|
|
|
// **Do not mention 18+ to an account that does not have it.** This label used to
|
|
|
|
|
// read "skryje anime a 18+" for everyone, which told most of the household that a
|
|
|
|
|
// section they have never seen and cannot reach exists at all. Three of the four
|
|
|
|
|
// accounts are `nsfwEnabled = false`, and for them the sentence was also simply
|
|
|
|
|
// untrue: there is no 18+ content to hide. The adult source field on the Zdroje
|
|
|
|
|
// tab has always been gated on the same flag; this label was the one place that
|
|
|
|
|
// was not.
|
|
|
|
|
$("pfChildLabel").textContent = (USER && USER.nsfwEnabled)
|
|
|
|
|
? "Dětský profil (skryje anime a 18+, na odchod chce PIN)"
|
|
|
|
|
: "Dětský profil (skryje anime, na odchod chce PIN)";
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
renderAddonGate();
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
renderStart();
|
|
|
|
|
// An explicit #hash always wins — a bookmark or a link must land where it says.
|
|
|
|
|
// Otherwise: the setup flow for someone who has no sources yet and has not
|
|
|
|
|
// said "later", and profiles for everyone else. Which means the whole family,
|
|
|
|
|
// who all have sources already, never sees it.
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
var want=(location.hash||"").replace("#","");
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
if (TABS.some(function(t){return t[0]===want;})) showTab(want);
|
|
|
|
|
else showTab(setupDone() || onb().skipped ? "profiles" : "start");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── setup flow ───────────────────────────────────────────────────────────────
|
|
|
|
|
// Six steps in front of the tabs that already do the work. This screen owns no
|
|
|
|
|
// credentials and no crypto: steps 1, 5 and 6 hand off to Účet, Zdroje and
|
|
|
|
|
// Stáhnout. Re-implementing the vault here would mean two encryptors for one
|
|
|
|
|
// blob, which is how a vault gets corrupted.
|
|
|
|
|
//
|
|
|
|
|
// **Why the password is step 1 and not step 6.** Changing it re-keys the vault
|
|
|
|
|
// with a fresh salt, so every device already signed in holds a stale key until it
|
|
|
|
|
// authenticates again. Done first, while the vault is still empty, there is
|
|
|
|
|
// nothing to re-encrypt and no paired television to strand — and the password is
|
|
|
|
|
// already in memory from the sign-in that just happened, so it costs nothing.
|
|
|
|
|
//
|
|
|
|
|
// **What is stored and what is derived.** Only `skipped`, `chosen` and
|
|
|
|
|
// `pwChangedAt` live on the user record, because nothing else has to: whether the
|
|
|
|
|
// sources exist is `CONFIGS.length`, and whether TMDB is set is a field in the
|
|
|
|
|
// decrypted config. A second copy of a derivable fact eventually disagrees with
|
|
|
|
|
// the first, and then the flow either nags someone who has finished or
|
|
|
|
|
// congratulates someone who has not.
|
|
|
|
|
var PROVIDERS=[];
|
|
|
|
|
|
|
|
|
|
function onb(){
|
|
|
|
|
var o = USER && USER.onboarding;
|
|
|
|
|
return (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
|
|
|
|
|
}
|
|
|
|
|
async function saveOnb(patch){
|
|
|
|
|
var next = Object.assign({}, onb(), patch);
|
|
|
|
|
var r = await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
|
|
|
{ onboarding: next });
|
|
|
|
|
// Keep the screen truthful even if the write failed — the alternative is a
|
|
|
|
|
// checkbox that springs back with no explanation.
|
|
|
|
|
if (r.ok) USER = r.data; else USER.onboarding = next;
|
|
|
|
|
return r.ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sources exist at all. Deliberately not "is the config complete": a row
|
|
|
|
|
// existing is knowable without the password, so this still answers on a
|
|
|
|
|
// returning visit where the vault is locked.
|
|
|
|
|
function setupDone(){ return CONFIGS.length > 0; }
|
|
|
|
|
|
|
|
|
|
// The list only, no decryption — see above. `loadAddons` re-fetches and decrypts
|
|
|
|
|
// once the vault is unlocked.
|
|
|
|
|
async function loadConfigList(){
|
|
|
|
|
var r = await api("GET","/api/collections/addon_config/records?perPage=200");
|
|
|
|
|
if (r.ok) CONFIGS = r.data.items || [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadProviders(){
|
|
|
|
|
var r = await api("GET","/api/collections/providers/records?perPage=50&sort=sort"
|
|
|
|
|
+ "&filter=" + encodeURIComponent("enabled=true"));
|
|
|
|
|
PROVIDERS = r.ok ? (r.data.items||[]) : [];
|
|
|
|
|
}
|
|
|
|
|
|
The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".
So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.
Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.
status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.
The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.
Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
|
|
|
// Jump to the code box in Zdroje. Locked vault: land on the section and let the
|
|
|
|
|
// viewer unlock, rather than reaching for a form that is not built yet.
|
|
|
|
|
function gotoCode(){
|
|
|
|
|
showTab("addons");
|
|
|
|
|
var setup=$("adSetup");
|
|
|
|
|
if (PASSWORD && setup && setup.className.indexOf("hidden") < 0) $("adFamily").click();
|
|
|
|
|
}
|
|
|
|
|
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
function stP(parent, text, cls){
|
|
|
|
|
var p=document.createElement("p"); p.textContent=text;
|
|
|
|
|
if (cls) p.className=cls; parent.appendChild(p); return p;
|
|
|
|
|
}
|
|
|
|
|
function stBtn(parent, label, cls, fn){
|
|
|
|
|
var b=document.createElement("button"); b.className="btn "+(cls||"ghost");
|
|
|
|
|
b.textContent=label; b.onclick=fn; parent.appendChild(b); return b;
|
|
|
|
|
}
|
|
|
|
|
function stLink(parent, label, href){
|
|
|
|
|
var a=document.createElement("a"); a.href=href; a.target="_blank";
|
|
|
|
|
a.rel="noopener noreferrer"; a.textContent=label;
|
|
|
|
|
a.style.cssText="display:inline-block;margin-top:10px;color:var(--amber);font-size:14px;font-weight:700";
|
|
|
|
|
parent.appendChild(a); return a;
|
|
|
|
|
}
|
|
|
|
|
function stList(parent, items, cls){
|
|
|
|
|
if (!Array.isArray(items) || !items.length) return null;
|
|
|
|
|
var ul=document.createElement("ul"); if (cls) ul.className=cls;
|
|
|
|
|
items.forEach(function(t){
|
|
|
|
|
var li=document.createElement("li"); li.textContent=String(t); ul.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
parent.appendChild(ul); return ul;
|
|
|
|
|
}
|
|
|
|
|
// A signup guide is an ORDERED list, and an `<ul>` here is not a styling
|
|
|
|
|
// preference — someone following it with the provider's form open in the next tab
|
|
|
|
|
// needs to know which step they are on, and bullets cannot tell them.
|
|
|
|
|
function stOl(parent, items){
|
|
|
|
|
if (!Array.isArray(items) || !items.length) return null;
|
|
|
|
|
var ol=document.createElement("ol"); ol.className="guide";
|
|
|
|
|
items.forEach(function(t){
|
|
|
|
|
var li=document.createElement("li"); li.textContent=String(t); ol.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
parent.appendChild(ol); return ol;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One service, with the numbers and the honest downsides. Everything here is
|
|
|
|
|
// content from the `providers` collection, so a price is corrected in the admin
|
|
|
|
|
// UI and not in this file.
|
|
|
|
|
function provCard(p, chosen, onToggle){
|
|
|
|
|
var d=document.createElement("div");
|
|
|
|
|
d.className="prov"+(chosen?" on":"");
|
|
|
|
|
var top=document.createElement("div"); top.className="top";
|
|
|
|
|
var left=document.createElement("div");
|
|
|
|
|
var nm=document.createElement("b"); nm.textContent=p.name||p.slug;
|
|
|
|
|
left.appendChild(nm);
|
|
|
|
|
if (p.recommended){
|
|
|
|
|
var bd=document.createElement("span"); bd.className="badge";
|
|
|
|
|
bd.textContent="doporučeno"; left.appendChild(bd);
|
|
|
|
|
}
|
|
|
|
|
// prehraj.to's free month is the most persuasive fact in the whole flow —
|
|
|
|
|
// someone can reach a working Amber with Czech dubs for the kids without
|
|
|
|
|
// spending anything. It earns a badge rather than a line of small print.
|
|
|
|
|
if (/30 dní/i.test(p.priceNote||"")){
|
|
|
|
|
var tr=document.createElement("span"); tr.className="badge soft";
|
|
|
|
|
tr.textContent="30 dní zdarma"; left.appendChild(tr);
|
|
|
|
|
}
|
|
|
|
|
top.appendChild(left);
|
|
|
|
|
var pr=document.createElement("span"); pr.className="price";
|
|
|
|
|
pr.textContent=p.price||""; top.appendChild(pr);
|
|
|
|
|
d.appendChild(top);
|
|
|
|
|
if (p.tagline) stP(d, p.tagline, "hint");
|
|
|
|
|
stList(d, p.pros);
|
|
|
|
|
stList(d, p.cons, "cons");
|
|
|
|
|
if (p.priceNote) stP(d, p.priceNote, "hint");
|
|
|
|
|
if (p.priceCheckedAt){
|
|
|
|
|
stP(d, "Cena ověřena " + String(p.priceCheckedAt).slice(0,10) + ".", "hint");
|
|
|
|
|
}
|
|
|
|
|
var row=document.createElement("div"); row.className="check";
|
|
|
|
|
var cb=document.createElement("input"); cb.type="checkbox"; cb.checked=chosen;
|
|
|
|
|
cb.id="prov-"+p.slug;
|
|
|
|
|
var lb=document.createElement("label"); lb.style.margin="0";
|
|
|
|
|
lb.setAttribute("for", cb.id);
|
|
|
|
|
lb.textContent="Chci používat " + (p.name||p.slug);
|
|
|
|
|
cb.onchange=function(){ onToggle(p.slug, cb.checked); };
|
|
|
|
|
row.appendChild(cb); row.appendChild(lb); d.appendChild(row);
|
|
|
|
|
return d;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stStep(n, title, state, buildBody){
|
|
|
|
|
var d=document.createElement("div");
|
|
|
|
|
d.className="step" + (state==="done" ? " done" : state==="now" ? " now" : "");
|
|
|
|
|
var h=document.createElement("h3");
|
|
|
|
|
var num=document.createElement("span"); num.className="num";
|
|
|
|
|
num.textContent = state==="done" ? "✓" : String(n);
|
|
|
|
|
h.appendChild(num); h.appendChild(document.createTextNode(title));
|
|
|
|
|
d.appendChild(h);
|
|
|
|
|
if (buildBody){
|
|
|
|
|
var b=document.createElement("div"); b.className="body";
|
|
|
|
|
buildBody(b, state);
|
|
|
|
|
if (b.childNodes.length) d.appendChild(b);
|
|
|
|
|
}
|
|
|
|
|
return d;
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
}
|
|
|
|
|
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
function renderStart(){
|
|
|
|
|
var wrap=$("stList"); if(!wrap) return;
|
|
|
|
|
wrap.innerHTML="";
|
|
|
|
|
var o=onb();
|
|
|
|
|
var chosen = Array.isArray(o.chosen) ? o.chosen.slice() : [];
|
|
|
|
|
var sources = PROVIDERS.filter(function(p){ return p.kind==="source"; });
|
|
|
|
|
var tmdb = PROVIDERS.filter(function(p){ return p.kind==="metadata"; })[0] || null;
|
|
|
|
|
|
|
|
|
|
var pwDone = !!o.pwChangedAt;
|
|
|
|
|
var pickDone = chosen.length > 0;
|
|
|
|
|
var srcDone = setupDone();
|
|
|
|
|
// Only answerable with the vault open. Locked, it reads as not-done, which
|
|
|
|
|
// shows a step that is already finished rather than hiding one that is not —
|
|
|
|
|
// the safe direction to be wrong in.
|
|
|
|
|
var tmdbDone = !!(cfg && cfg.tmdbKey);
|
|
|
|
|
|
|
|
|
|
var flags=[pwDone, pickDone, srcDone, tmdbDone, srcDone];
|
|
|
|
|
var current=flags.indexOf(false);
|
|
|
|
|
if (current<0) current=5; // everything done → the download step is live
|
|
|
|
|
function st(i, done){ return done ? "done" : (i===current ? "now" : "todo"); }
|
|
|
|
|
|
|
|
|
|
// 1 ── the password Richard generated
|
|
|
|
|
wrap.appendChild(stStep(1, "Změň si heslo", st(0, pwDone), function(b, state){
|
|
|
|
|
if (state==="done"){
|
|
|
|
|
stP(b, "Hotovo, heslo sis změnil " + String(o.pwChangedAt).slice(0,10) + ".", "hint");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
stP(b, "Přihlásil jsi se heslem, které ti vygeneroval Richard, takže ho zná. "
|
|
|
|
|
+ "Změň si ho teď, dokud u sebe nemáš nastavené zdroje: heslem se šifrují, "
|
|
|
|
|
+ "takže pozdější změna je nutí přešifrovat a odhlášená zařízení si na ně "
|
|
|
|
|
+ "musí říct znovu.");
|
|
|
|
|
stBtn(b, "Otevřít Účet", "primary", function(){ showTab("account"); });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 2 ── which services, at what price
|
|
|
|
|
wrap.appendChild(stStep(2, "Vyber si služby", st(1, pickDone), function(b, state){
|
|
|
|
|
if (state==="done"){
|
|
|
|
|
var names=chosen.map(function(s){
|
|
|
|
|
var p=PROVIDERS.filter(function(x){return x.slug===s;})[0];
|
|
|
|
|
return p ? (p.name||s) : s;
|
|
|
|
|
});
|
|
|
|
|
stP(b, "Vybral jsi: " + names.join(", ") + ".", "hint");
|
|
|
|
|
stBtn(b, "Změnit výběr", "ghost", async function(){
|
|
|
|
|
await saveOnb({ chosen: [] }); renderStart();
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!sources.length){
|
|
|
|
|
stP(b, "Seznam služeb se nepodařilo načíst. Zkus stránku obnovit. Zdroje "
|
|
|
|
|
+ "můžeš i tak nastavit ručně v sekci Zdroje.", "hint");
|
|
|
|
|
stBtn(b, "Otevřít Zdroje", "ghost", function(){ showTab("addons"); });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
stP(b, "Aspoň jedna stačí. Český host dělá české dabingy, TorBox dělá anime "
|
|
|
|
|
+ "a originální znění. Kdo chce obojí, vezme si obojí.");
|
The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".
So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.
Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.
status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.
The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.
Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
|
|
|
// Someone Richard shares his accounts with has nothing to buy, and being walked
|
|
|
|
|
// through three price lists first would be actively misleading.
|
|
|
|
|
stP(b, "Máš od Richarda kód? Pak nekupuj nic, kód ti zdroje nastaví sám.",
|
|
|
|
|
"hint");
|
|
|
|
|
stBtn(b, "Mám kód", "ghost", gotoCode);
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
var picked=chosen.slice();
|
|
|
|
|
sources.forEach(function(p){
|
|
|
|
|
b.appendChild(provCard(p, picked.indexOf(p.slug)>=0, function(slug, on){
|
|
|
|
|
var i=picked.indexOf(slug);
|
|
|
|
|
if (on && i<0) picked.push(slug);
|
|
|
|
|
if (!on && i>=0) picked.splice(i,1);
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
stBtn(b, "Pokračovat", "primary", async function(){
|
|
|
|
|
if (!picked.length){
|
|
|
|
|
setMsg($("stMsg"), "Vyber aspoň jednu službu.", "err"); return;
|
|
|
|
|
}
|
|
|
|
|
setMsg($("stMsg"), "");
|
|
|
|
|
await saveOnb({ chosen: picked });
|
|
|
|
|
renderStart();
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 3 ── the part only they can do
|
|
|
|
|
wrap.appendChild(stStep(3, "Založ si účty a zaplať", st(2, srcDone), function(b, state){
|
|
|
|
|
if (state==="done"){
|
|
|
|
|
stP(b, "Hotovo, tvoje údaje fungují, takže účty máš.", "hint");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
var picks=sources.filter(function(p){ return chosen.indexOf(p.slug)>=0; });
|
|
|
|
|
if (!picks.length){ stP(b, "Nejdřív si vyber služby v kroku 2.", "hint"); return; }
|
|
|
|
|
stP(b, "U každé vybrané služby si založ účet a aktivuj předplatné. Odkazy a "
|
|
|
|
|
+ "postup máš níž. Otevřou se v novém okně, takže tuhle stránku "
|
|
|
|
|
+ "neztratíš.");
|
|
|
|
|
picks.forEach(function(p){
|
|
|
|
|
var h=document.createElement("h3"); h.style.marginTop="14px";
|
|
|
|
|
h.textContent=p.name||p.slug; b.appendChild(h);
|
|
|
|
|
if (p.price) stP(b, p.price + (p.priceNote ? ". " + p.priceNote : ""), "hint");
|
|
|
|
|
stOl(b, p.steps);
|
|
|
|
|
if (p.url) stLink(b, "Otevřít " + (p.name||p.slug), p.url);
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 4 ── the free key nobody expects to need
|
|
|
|
|
wrap.appendChild(stStep(4, "Vezmi si klíč z TMDB", st(3, tmdbDone), function(b, state){
|
|
|
|
|
if (state==="done"){ stP(b, "Hotovo, klíč je uložený.", "hint"); return; }
|
|
|
|
|
if (!tmdb){
|
|
|
|
|
stP(b, "Postup se nepodařilo načíst. Klíč seženeš zdarma na "
|
|
|
|
|
+ "themoviedb.org a vloží se v sekci Zdroje.", "hint");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (tmdb.tagline) stP(b, tmdb.tagline);
|
|
|
|
|
stOl(b, tmdb.steps);
|
|
|
|
|
if (tmdb.url) stLink(b, "Otevřít TMDB", tmdb.url);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 5 ── hand off to the box that already does this
|
|
|
|
|
wrap.appendChild(stStep(5, "Vlož údaje do Amberu", st(4, srcDone), function(b, state){
|
|
|
|
|
if (state==="done"){
|
|
|
|
|
stP(b, "Hotovo, zdroje máš nastavené. Aplikace si je stáhne sama.", "hint");
|
|
|
|
|
stBtn(b, "Zkontrolovat Zdroje", "ghost", function(){ showTab("addons"); });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
stP(b, "V sekci Zdroje vyber „Mám vlastní účty u služeb“ a vyplň jméno, heslo "
|
|
|
|
|
+ "a klíče. Amber si z nich adresy zdrojů poskládá sám. Údaje jdou přímo "
|
|
|
|
|
+ "do doplňku a přes náš server neprojdou.");
|
|
|
|
|
stBtn(b, "Otevřít Zdroje", "primary", function(){ showTab("addons"); });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 6 ── no completion state: you can always want the app again
|
|
|
|
|
wrap.appendChild(stStep(6, "Stáhni Amber", srcDone ? "now" : "todo", function(b){
|
|
|
|
|
stP(b, "Nainstaluj Amber na televizi, telefon nebo počítač a přihlas se. "
|
|
|
|
|
+ "Televizi můžeš spárovat v sekci Zařízení, ať nemusíš heslo psát "
|
|
|
|
|
+ "ovladačem.");
|
|
|
|
|
stBtn(b, "Otevřít Stáhnout", srcDone ? "primary" : "ghost", function(){
|
|
|
|
|
showTab("download");
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// The banner is the way back for anyone who chose "později". Without it,
|
|
|
|
|
// skipping and finishing look identical and the flow is gone for good.
|
|
|
|
|
$("stBanner").className = (!srcDone && o.skipped) ? "warn" : "warn hidden";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("stLater").onclick=async function(){
|
|
|
|
|
await saveOnb({ skipped:true });
|
|
|
|
|
renderStart();
|
|
|
|
|
showTab("profiles");
|
|
|
|
|
};
|
|
|
|
|
$("stResume").onclick=async function(){
|
|
|
|
|
await saveOnb({ skipped:false });
|
|
|
|
|
renderStart();
|
|
|
|
|
showTab("start");
|
|
|
|
|
};
|
|
|
|
|
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
// ── profiles ─────────────────────────────────────────────────────────────────
|
|
|
|
|
var TIERS={ "":"Podle účtu", kids:"Děti (do 7)", twelve:"12+",
|
|
|
|
|
fifteen:"15+", unrestricted:"Bez omezení" };
|
|
|
|
|
async function loadProfiles(){
|
|
|
|
|
var r=await api("GET","/api/collections/profiles/records?perPage=200&sort=created");
|
|
|
|
|
PROFILES = r.ok ? (r.data.items||[]) : [];
|
|
|
|
|
var ul=$("profileList"); ul.innerHTML="";
|
|
|
|
|
PROFILES.forEach(function(p){
|
|
|
|
|
var li=document.createElement("li");
|
|
|
|
|
li.innerHTML="<b>"+esc(p.name||"Profil")+"</b>"+
|
|
|
|
|
'<div class="meta">'+(p.isChild?"dětský · ":"")+
|
|
|
|
|
(TIERS[p.maxRating||""]||p.maxRating)+"</div>";
|
|
|
|
|
ul.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
var sels=[$("pfSelect"), $("prProfile")];
|
|
|
|
|
sels.forEach(function(sel){
|
|
|
|
|
var keep=sel.value; sel.innerHTML="";
|
|
|
|
|
PROFILES.forEach(function(p){
|
|
|
|
|
var o=document.createElement("option"); o.value=p.id;
|
|
|
|
|
o.textContent=p.name||"Profil"; sel.appendChild(o);
|
|
|
|
|
});
|
|
|
|
|
if (keep) sel.value=keep;
|
|
|
|
|
});
|
|
|
|
|
if (PROFILES.length){ fillProfileForm(); await loadPrefs(); }
|
|
|
|
|
}
|
|
|
|
|
function esc(s){ return String(s).replace(/[<>&"]/g,function(c){
|
|
|
|
|
return ({"<":"<",">":">","&":"&",'"':"""})[c]; }); }
|
|
|
|
|
function currentProfile(){ return PROFILES.filter(function(p){
|
|
|
|
|
return p.id===$("pfSelect").value; })[0] || PROFILES[0]; }
|
|
|
|
|
function fillProfileForm(){
|
|
|
|
|
var p=currentProfile(); if(!p) return;
|
|
|
|
|
$("pfName2").value=p.name||""; $("pfRating").value=p.maxRating||"";
|
|
|
|
|
$("pfChild").checked=!!p.isChild; $("pfPin").value="";
|
|
|
|
|
}
|
|
|
|
|
$("pfSelect").onchange=fillProfileForm;
|
|
|
|
|
|
|
|
|
|
$("pfSave").onclick=async function(){
|
|
|
|
|
var p=currentProfile(); if(!p) return;
|
|
|
|
|
var pin=$("pfPin").value.trim();
|
|
|
|
|
if (pin && !/^\d{4}$/.test(pin)){ setMsg($("pfMsg"),"PIN musí být 4 číslice.","err"); return; }
|
|
|
|
|
busy($("pfSave"),true,"Ukládám…"); setMsg($("pfMsg"),"");
|
|
|
|
|
var r=await api("PATCH","/api/collections/profiles/records/"+p.id, {
|
|
|
|
|
name: $("pfName2").value.trim() || "Profil",
|
|
|
|
|
isChild: $("pfChild").checked,
|
|
|
|
|
maxRating: $("pfRating").value,
|
|
|
|
|
});
|
|
|
|
|
if (r.ok && pin){
|
|
|
|
|
// pinHash is a hidden field — PocketBase silently drops a client PATCH to
|
|
|
|
|
// it, so the PIN must go through the server route that hashes it.
|
|
|
|
|
var pr=await api("POST","/api/amber/set-pin",{ profileId:p.id, pin:pin });
|
|
|
|
|
if (!pr.ok) r={ok:false};
|
2026-07-22 16:23:05 +00:00
|
|
|
}
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
busy($("pfSave"),false);
|
|
|
|
|
setMsg($("pfMsg"), r.ok?"Uloženo.":"Uložení selhalo.", r.ok?"ok":"err");
|
|
|
|
|
if (r.ok) await loadProfiles();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── playback prefs ───────────────────────────────────────────────────────────
|
|
|
|
|
var LANGS=[["","Beze změny"],["cze","Čeština"],["slo","Slovenština"],
|
|
|
|
|
["eng","Angličtina"],["jpn","Japonština"],["ger","Němčina"],["pol","Polština"]];
|
|
|
|
|
function fillLangs(){
|
|
|
|
|
document.querySelectorAll("select.lang").forEach(function(sel){
|
|
|
|
|
sel.innerHTML="";
|
|
|
|
|
LANGS.forEach(function(l){
|
|
|
|
|
if (l[0]==="" ) return;
|
|
|
|
|
var o=document.createElement("option"); o.value=l[0]; o.textContent=l[1];
|
|
|
|
|
sel.appendChild(o);
|
|
|
|
|
});
|
|
|
|
|
var none=document.createElement("option");
|
|
|
|
|
none.value=""; none.textContent = sel.classList.contains("sub") ? "Žádné" : "Bez preference";
|
|
|
|
|
sel.insertBefore(none, sel.firstChild);
|
|
|
|
|
if (sel.classList.contains("sub")){
|
|
|
|
|
var off=document.createElement("option");
|
|
|
|
|
off.value="off"; off.textContent="Vypnuté"; sel.appendChild(off);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
async function loadPrefs(){
|
|
|
|
|
var pid=$("prProfile").value || (PROFILES[0]&&PROFILES[0].id);
|
|
|
|
|
if(!pid) return;
|
|
|
|
|
var r=await api("GET","/api/collections/prefs/records?perPage=1&filter="+
|
|
|
|
|
encodeURIComponent('profile="'+pid+'"'));
|
|
|
|
|
var rec=(r.ok && r.data.items && r.data.items[0]) || null;
|
|
|
|
|
PREFS[pid]=rec;
|
|
|
|
|
var d=(rec && rec.data) || {};
|
|
|
|
|
$("prAudio").value=d.audioLanguage||"cze";
|
|
|
|
|
$("prAudio2").value=d.audioLanguage2||"eng";
|
|
|
|
|
$("prSub").value=d.subtitleLanguage||"cze";
|
|
|
|
|
$("prSub2").value=d.subtitleLanguage2||"eng";
|
|
|
|
|
$("prAnimeAudio").value=d.animeAudioLanguage||"jpn";
|
|
|
|
|
$("prAnimeSub").value=d.animeSubtitleLanguage||"eng";
|
|
|
|
|
$("prRes").value=d.preferredResolution ? String(d.preferredResolution) : "";
|
|
|
|
|
$("prHighest").checked=d.preferHighestQuality!==false;
|
|
|
|
|
$("prSigns").checked=!!d.combineSignsAndDialogue;
|
|
|
|
|
$("prFeedback").value=d.feedbackPrompt||"sometimes";
|
|
|
|
|
}
|
|
|
|
|
$("prProfile").onchange=loadPrefs;
|
|
|
|
|
|
|
|
|
|
$("prSave").onclick=async function(){
|
|
|
|
|
var pid=$("prProfile").value; if(!pid) return;
|
|
|
|
|
busy($("prSave"),true,"Ukládám…"); setMsg($("prMsg"),"");
|
|
|
|
|
var rec=PREFS[pid];
|
|
|
|
|
var base=(rec && rec.data) || {};
|
|
|
|
|
// Merge, never replace: the app writes fields this page does not show yet, and
|
|
|
|
|
// a whole-object PUT would silently drop them.
|
|
|
|
|
var data=Object.assign({}, base, {
|
|
|
|
|
appLanguage: base.appLanguage || "cs",
|
|
|
|
|
audioLanguage: $("prAudio").value || null,
|
|
|
|
|
audioLanguage2: $("prAudio2").value || null,
|
|
|
|
|
subtitleLanguage: $("prSub").value || null,
|
|
|
|
|
subtitleLanguage2: $("prSub2").value || null,
|
|
|
|
|
animeAudioLanguage: $("prAnimeAudio").value || null,
|
|
|
|
|
animeSubtitleLanguage: $("prAnimeSub").value || null,
|
|
|
|
|
animeAudioLanguage2: base.animeAudioLanguage2 || "eng",
|
|
|
|
|
animeSubtitleLanguage2: base.animeSubtitleLanguage2 || "cze",
|
|
|
|
|
preferredResolution: $("prRes").value ? parseInt($("prRes").value,10) : null,
|
|
|
|
|
preferHighestQuality: $("prHighest").checked,
|
|
|
|
|
combineSignsAndDialogue: $("prSigns").checked,
|
|
|
|
|
feedbackPrompt: $("prFeedback").value,
|
|
|
|
|
});
|
|
|
|
|
// updatedAt is the LWW clock the app compares on; the server's `updated` is
|
|
|
|
|
// only the pull cursor. Omitting this would make the edit lose to a stale
|
|
|
|
|
// device, or clobber a newer one unpredictably.
|
|
|
|
|
var body={ profile:pid, data:data, updatedAt:new Date().toISOString() };
|
|
|
|
|
var r = rec
|
|
|
|
|
? await api("PATCH","/api/collections/prefs/records/"+rec.id, body)
|
|
|
|
|
: await api("POST","/api/collections/prefs/records", body);
|
|
|
|
|
busy($("prSave"),false);
|
|
|
|
|
setMsg($("prMsg"), r.ok?"Uloženo. Zařízení si to stáhnou při dalším spuštění."
|
|
|
|
|
:"Uložení selhalo.", r.ok?"ok":"err");
|
|
|
|
|
if (r.ok) await loadPrefs();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── addons ───────────────────────────────────────────────────────────────────
|
|
|
|
|
function renderAddonGate(){
|
|
|
|
|
var have = !!PASSWORD;
|
|
|
|
|
$("adNeedPass").className = have ? "hidden" : "";
|
|
|
|
|
$("adBody").className = have ? "" : "hidden";
|
|
|
|
|
if (have) loadAddons();
|
|
|
|
|
}
|
|
|
|
|
$("adUnlock").onclick=async function(){
|
|
|
|
|
var p=$("adPass").value; if(!p){ return; }
|
|
|
|
|
busy($("adUnlock"),true,"Ověřuji…");
|
|
|
|
|
// Verify the password by authenticating, rather than by trying to decrypt —
|
|
|
|
|
// a wrong password and an absent config look identical to the decrypt path.
|
|
|
|
|
var r=await api("POST","/api/collections/users/auth-with-password",
|
|
|
|
|
{ identity:USER.email, password:p }, {anon:true});
|
|
|
|
|
busy($("adUnlock"),false);
|
|
|
|
|
if(!r.ok){ setMsg($("adMsg"),"Špatné heslo.","err"); return; }
|
|
|
|
|
TOKEN=r.data.token; USER=r.data.record; PASSWORD=p; saveSession();
|
|
|
|
|
setMsg($("adMsg"),""); $("adPass").value="";
|
|
|
|
|
renderAddonGate();
|
|
|
|
|
$("acChange").disabled=false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function loadAddons(){
|
|
|
|
|
var r=await api("GET","/api/collections/addon_config/records?perPage=200");
|
|
|
|
|
CONFIGS = r.ok ? (r.data.items||[]) : [];
|
|
|
|
|
cfgRec = CONFIGS[0] || null;
|
|
|
|
|
cfg=null;
|
|
|
|
|
if (cfgRec){
|
|
|
|
|
try { cfg=JSON.parse(await decryptBlob(cfgRec.blob, PASSWORD, cfgRec.salt)); }
|
|
|
|
|
catch(_){ cfg=null; }
|
2026-07-22 16:23:05 +00:00
|
|
|
}
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
var configured = cfg && (cfg.addonUrl || cfg.czechAddonUrl);
|
|
|
|
|
$("adSetup").className = configured ? "hidden" : "";
|
|
|
|
|
$("adEdit").className = configured ? "" : "hidden";
|
|
|
|
|
$("adFamilyBox").className="hidden"; $("adByocBox").className="hidden";
|
|
|
|
|
if (configured){
|
|
|
|
|
$("adTorbox").value=cfg.addonUrl||"";
|
|
|
|
|
$("adCzech").value=cfg.czechAddonUrl||"";
|
|
|
|
|
$("adTmdb").value=cfg.tmdbKey||"";
|
|
|
|
|
if (USER && USER.nsfwEnabled){
|
|
|
|
|
$("adAdultBox").className="";
|
|
|
|
|
$("adAdult").value=cfg.adultAddonUrl||"";
|
|
|
|
|
}
|
2026-07-22 16:23:05 +00:00
|
|
|
}
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
$("acChange").disabled = !PASSWORD;
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
// Unlocking or saving here is what finishes steps 4 and 5, so the flow has to
|
|
|
|
|
// be told. Cheap: it only re-renders one hidden section.
|
|
|
|
|
renderStart();
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
}
|
|
|
|
|
$("adFamily").onclick=function(){ $("adSetup").className="hidden"; $("adFamilyBox").className=""; };
|
|
|
|
|
$("adByoc").onclick=function(){ $("adSetup").className="hidden"; $("adByocBox").className=""; };
|
|
|
|
|
document.querySelectorAll(".adBack").forEach(function(b){
|
|
|
|
|
b.onclick=function(){ $("adFamilyBox").className="hidden";
|
|
|
|
|
$("adByocBox").className="hidden"; $("adSetup").className=""; setMsg($("adMsg"),""); };
|
2026-07-22 16:23:05 +00:00
|
|
|
});
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
$("adRedo").onclick=function(){ $("adEdit").className="hidden"; $("adSetup").className=""; };
|
|
|
|
|
|
|
|
|
|
// family code → the shared template
|
|
|
|
|
$("adCodeGo").onclick=async function(){
|
|
|
|
|
var code=$("adCode").value.trim();
|
|
|
|
|
if(!code){ setMsg($("adMsg"),"Zadej kód.","err"); return; }
|
|
|
|
|
busy($("adCodeGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
|
|
|
|
var r=await api("GET","/api/amber/onboarding-template?code="+encodeURIComponent(code));
|
|
|
|
|
if(!r.ok){
|
|
|
|
|
busy($("adCodeGo"),false);
|
|
|
|
|
setMsg($("adMsg"), r.status===403 ? "Neplatný kód."
|
|
|
|
|
: r.status===429 ? "Moc pokusů, zkus to za chvíli."
|
The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".
So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.
Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.
status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.
The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.
Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
|
|
|
: "Zdroje se nepodařilo načíst.","err");
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await saveConfig({ addonUrl:r.data.addonUrl||"", czechAddonUrl:r.data.czechAddonUrl||"",
|
|
|
|
|
tmdbKey:r.data.tmdbKey||"" }, $("adCodeGo"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// BYOC → mint the URLs against the addons themselves
|
|
|
|
|
$("byGo").onclick=async function(){
|
|
|
|
|
var ptU=$("byPtUser").value.trim(), ptP=$("byPtPass").value;
|
|
|
|
|
var wsU=$("byWsUser").value.trim(), wsP=$("byWsPass").value;
|
|
|
|
|
var tb=$("byTbKey").value.trim(), tmdb=$("byTmdb").value.trim();
|
2026-08-07 07:09:56 +00:00
|
|
|
// Each host is all-or-nothing, and at least one must be complete — the same
|
|
|
|
|
// rule the addon's own /encode enforces, checked here so the message is Czech.
|
|
|
|
|
if ((ptU && !ptP) || (!ptU && ptP)){
|
|
|
|
|
setMsg($("adMsg"),"U prehraj.to vyplň jméno i heslo, nebo obojí nech prázdné.","err");
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if ((wsU && !wsP) || (!wsU && wsP)){
|
|
|
|
|
setMsg($("adMsg"),"U webshare vyplň jméno i heslo, nebo obojí nech prázdné.","err");
|
|
|
|
|
return;
|
|
|
|
|
}
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
// **One source of any kind is enough — a Czech host or TorBox.** This used to
|
|
|
|
|
// demand a Czech host, which made TorBox-only impossible to save even though
|
|
|
|
|
// nothing downstream requires a Czech addon: `CompositeStreamProvider` takes
|
|
|
|
|
// whatever is configured, and the Czech-specific paths key off a source's
|
|
|
|
|
// `providerId`, so they simply never fire. Someone who only watches anime or
|
|
|
|
|
// original-audio releases was being told to buy a service they had no use for.
|
|
|
|
|
var hasCzech = (ptU && ptP) || (wsU && wsP);
|
|
|
|
|
if (!hasCzech && !tb){
|
|
|
|
|
setMsg($("adMsg"),
|
|
|
|
|
"Vyplň aspoň jeden zdroj: českého hosta, nebo TorBox.","err");
|
2026-08-07 07:09:56 +00:00
|
|
|
return;
|
|
|
|
|
}
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
busy($("byGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
|
|
|
|
var out={ tmdbKey:tmdb };
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
// Only ask the Czech addon for a token when there are credentials for it.
|
|
|
|
|
// Calling /encode with nothing is a guaranteed 400, which is what made the
|
|
|
|
|
// TorBox-only case fail with "Český doplněk odmítl údaje" — an error about a
|
|
|
|
|
// service the person had deliberately left blank.
|
|
|
|
|
if (hasCzech){
|
|
|
|
|
try {
|
|
|
|
|
// Straight to the addon. Both send permissive CORS, so the credentials go
|
|
|
|
|
// from this browser to the addon and never through our server.
|
|
|
|
|
var enc=await fetch(PREHRAJTO_BASE+"/encode",{
|
|
|
|
|
method:"POST", headers:{"Content-Type":"application/json"},
|
|
|
|
|
body:JSON.stringify({ username:ptU||"", password:ptP||"",
|
|
|
|
|
wsUsername:wsU||"", wsPassword:wsP||"" })});
|
|
|
|
|
var ed=await enc.json();
|
|
|
|
|
if(!enc.ok || !ed.token) throw new Error(ed.error||"encode failed");
|
|
|
|
|
out.czechAddonUrl = PREHRAJTO_BASE+"/"+ed.token+"/manifest.json";
|
|
|
|
|
} catch(err){
|
|
|
|
|
busy($("byGo"),false);
|
|
|
|
|
setMsg($("adMsg"),"Český doplněk odmítl údaje: "+err.message,"err");
|
|
|
|
|
return;
|
|
|
|
|
}
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
}
|
|
|
|
|
if (tb){
|
|
|
|
|
// The TorBox addon has no /encode — its config token is base64url of the
|
|
|
|
|
// same JSON its own configure page builds.
|
|
|
|
|
out.addonUrl = TORBOX_BASE+"/"+b64url(JSON.stringify({
|
|
|
|
|
torboxApiKey: tb, language: "cs-CZ" }));
|
|
|
|
|
}
|
|
|
|
|
await saveConfig(out, $("byGo"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function saveConfig(next, btn){
|
|
|
|
|
var merged=Object.assign({}, cfg||{}, next);
|
|
|
|
|
var salt=(cfgRec && cfgRec.salt) || newSaltB64();
|
|
|
|
|
var blob=await encryptWithSalt(JSON.stringify(merged), PASSWORD, salt);
|
|
|
|
|
var body={ blob:blob, salt:salt, kdf:KDF_ID, updatedAt:new Date().toISOString() };
|
|
|
|
|
var r;
|
|
|
|
|
if (cfgRec){
|
|
|
|
|
r=await api("PATCH","/api/collections/addon_config/records/"+cfgRec.id, body);
|
|
|
|
|
} else {
|
|
|
|
|
if (!PROFILES.length){ setMsg($("adMsg"),"Účet nemá profil.","err");
|
|
|
|
|
if(btn) busy(btn,false); return; }
|
|
|
|
|
body.profile=PROFILES[0].id;
|
|
|
|
|
r=await api("POST","/api/collections/addon_config/records", body);
|
|
|
|
|
}
|
|
|
|
|
if(btn) busy(btn,false);
|
|
|
|
|
setMsg($("adMsg"), r.ok ? "Zdroje uloženy. Aplikace si je stáhne sama."
|
|
|
|
|
: "Uložení selhalo.", r.ok?"ok":"err");
|
|
|
|
|
if (r.ok) await loadAddons();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("adSave").onclick=async function(){
|
|
|
|
|
busy($("adSave"),true,"Ukládám…");
|
|
|
|
|
var next={ addonUrl:$("adTorbox").value.trim(),
|
|
|
|
|
czechAddonUrl:$("adCzech").value.trim(),
|
|
|
|
|
tmdbKey:$("adTmdb").value.trim() };
|
|
|
|
|
if (USER && USER.nsfwEnabled) next.adultAddonUrl=$("adAdult").value.trim();
|
|
|
|
|
await saveConfig(next, $("adSave"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── devices ──────────────────────────────────────────────────────────────────
|
|
|
|
|
async function loadDevices(){
|
|
|
|
|
var r=await api("GET","/api/collections/device_auth/records?perPage=200&sort=-created");
|
|
|
|
|
var ul=$("deviceList"); ul.innerHTML="";
|
|
|
|
|
var items=(r.ok && r.data.items) || [];
|
|
|
|
|
if(!items.length){ ul.innerHTML='<li class="meta">Zatím žádná zařízení.</li>'; return; }
|
|
|
|
|
items.forEach(function(d){
|
|
|
|
|
var li=document.createElement("li");
|
|
|
|
|
var wrap=document.createElement("div"); wrap.className="rowbtn";
|
|
|
|
|
var info=document.createElement("div");
|
|
|
|
|
info.innerHTML="<b>"+esc(d.deviceName||"Zařízení")+"</b>"+
|
|
|
|
|
'<div class="meta">'+esc(d.status||"")+" · "+String(d.created||"").slice(0,10)+"</div>";
|
|
|
|
|
var btn=document.createElement("button"); btn.className="danger"; btn.textContent="Odebrat";
|
|
|
|
|
btn.onclick=async function(){
|
|
|
|
|
busy(btn,true,"…");
|
|
|
|
|
var dr=await api("DELETE","/api/collections/device_auth/records/"+d.id);
|
|
|
|
|
setMsg($("dvMsg"), dr.ok?"Zařízení odebráno.":"Odebrání selhalo.", dr.ok?"ok":"err");
|
|
|
|
|
await loadDevices();
|
|
|
|
|
};
|
|
|
|
|
wrap.appendChild(info); wrap.appendChild(btn); li.appendChild(wrap); ul.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── downloads ────────────────────────────────────────────────────────────────
|
|
|
|
|
async function loadDownloads(){
|
|
|
|
|
var box=$("dlList"); box.innerHTML="";
|
|
|
|
|
var plats=[["android","Android TV / telefon"],["windows","Windows"],["linux","Linux"]];
|
|
|
|
|
var any=false;
|
|
|
|
|
for (var i=0;i<plats.length;i++){
|
|
|
|
|
var p=plats[i];
|
|
|
|
|
var m=await api("GET","/api/update/manifest?platform="+p[0]);
|
|
|
|
|
var li=document.createElement("li");
|
|
|
|
|
li.style.cssText="padding:12px;border:1px solid var(--line);border-radius:10px;"+
|
|
|
|
|
"margin-bottom:8px;background:var(--sunk);font-size:14px;list-style:none";
|
|
|
|
|
if (m.ok && m.data && m.data.available){
|
|
|
|
|
// `releases.file` is a protected file: the path alone 403s, it needs a
|
|
|
|
|
// short-lived file token. Minted per link, exactly as the landing page does.
|
|
|
|
|
var t=await api("POST","/api/files/token",null);
|
|
|
|
|
var href=(t.ok && t.data && t.data.token)
|
|
|
|
|
? m.data.downloadPath+"?token="+encodeURIComponent(t.data.token) : null;
|
|
|
|
|
li.innerHTML='<div class="rowbtn"><div><b>'+esc(p[1])+'</b><div class="meta">'+
|
|
|
|
|
esc(m.data.version||"")+" · "+esc(m.data.variant||"")+'</div></div>'+
|
|
|
|
|
(href ? '<a class="btn primary" style="width:auto;padding:9px 14px;margin:0;'+
|
|
|
|
|
'text-decoration:none;display:inline-block" href="'+href+'">Stáhnout</a>'
|
|
|
|
|
: '<span class="meta">odkaz se nepodařilo vytvořit</span>')+'</div>';
|
|
|
|
|
any=true;
|
|
|
|
|
} else {
|
|
|
|
|
li.innerHTML="<b>"+esc(p[1])+'</b><div class="meta">zatím nic ke stažení</div>';
|
|
|
|
|
}
|
|
|
|
|
box.appendChild(li);
|
|
|
|
|
}
|
|
|
|
|
if (!any) setMsg($("dlMsg"),"Zatím není publikovaná žádná verze.","");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── account ──────────────────────────────────────────────────────────────────
|
|
|
|
|
$("acSaveRating").onclick=async function(){
|
|
|
|
|
busy($("acSaveRating"),true,"Ukládám…");
|
|
|
|
|
var r=await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
|
|
|
{ ratingDefault: $("acRating").value });
|
|
|
|
|
busy($("acSaveRating"),false);
|
|
|
|
|
if (r.ok) USER=r.data;
|
|
|
|
|
setMsg($("acMsg"), r.ok?"Uloženo.":"Uložení selhalo.", r.ok?"ok":"err");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$("acChange").onclick=async function(){
|
|
|
|
|
var oldP=$("acOld").value, n1=$("acNew").value, n2=$("acNew2").value;
|
|
|
|
|
if (!PASSWORD){ setMsg($("acMsg"),"Nejdřív odemkni Zdroje — jinak přijdeš o nastavení zdrojů.","err"); return; }
|
|
|
|
|
if (n1.length<8){ setMsg($("acMsg"),"Nové heslo musí mít aspoň 8 znaků.","err"); return; }
|
|
|
|
|
if (n1!==n2){ setMsg($("acMsg"),"Nová hesla se neshodují.","err"); return; }
|
|
|
|
|
busy($("acChange"),true,"Měním…"); setMsg($("acMsg"),"");
|
|
|
|
|
|
|
|
|
|
// Re-key BEFORE changing the password: if the PATCH succeeded and the
|
|
|
|
|
// re-encrypt then failed, the vault would be unreadable by every device and
|
|
|
|
|
// the family would lose their sources. Doing it in this order means a failure
|
|
|
|
|
// here leaves everything exactly as it was.
|
|
|
|
|
var reEncrypted=[];
|
|
|
|
|
try {
|
|
|
|
|
for (var i=0;i<CONFIGS.length;i++){
|
|
|
|
|
var rec=CONFIGS[i];
|
|
|
|
|
var clear=await decryptBlob(rec.blob, oldP, rec.salt);
|
|
|
|
|
var salt=newSaltB64(); // new password ⇒ new key ⇒ new salt
|
|
|
|
|
reEncrypted.push({ id:rec.id,
|
|
|
|
|
blob: await encryptWithSalt(clear, n1, salt), salt: salt });
|
|
|
|
|
}
|
|
|
|
|
} catch(_){
|
|
|
|
|
busy($("acChange"),false);
|
|
|
|
|
setMsg($("acMsg"),"Současné heslo nesedí ke tvým zdrojům. Heslo jsem nezměnil.","err");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var r=await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
|
|
|
{ oldPassword:oldP, password:n1, passwordConfirm:n2 });
|
|
|
|
|
if(!r.ok){
|
|
|
|
|
busy($("acChange"),false);
|
|
|
|
|
setMsg($("acMsg"), r.status===400 ? "Současné heslo nesedí."
|
|
|
|
|
: "Změna hesla selhala.","err");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// The password change invalidates the token; re-authenticate before writing.
|
|
|
|
|
var a=await api("POST","/api/collections/users/auth-with-password",
|
|
|
|
|
{ identity:USER.email, password:n1 }, {anon:true});
|
Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:55 +00:00
|
|
|
if (a.ok){
|
|
|
|
|
TOKEN=a.data.token; USER=a.data.record; PASSWORD=n1; saveSession();
|
|
|
|
|
// The setup flow's first step is "replace the password Richard generated",
|
|
|
|
|
// and PocketBase keeps no password-changed timestamp — `updated` moves for any
|
|
|
|
|
// edit at all. So this write is the only thing that can ever answer it, and it
|
|
|
|
|
// has to happen here, after the re-auth, or the PATCH goes out on a token the
|
|
|
|
|
// password change already invalidated.
|
|
|
|
|
await saveOnb({ pwChangedAt: new Date().toISOString() });
|
|
|
|
|
}
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
var failed=0;
|
|
|
|
|
for (var j=0;j<reEncrypted.length;j++){
|
|
|
|
|
var e=reEncrypted[j];
|
|
|
|
|
var ur=await api("PATCH","/api/collections/addon_config/records/"+e.id,
|
|
|
|
|
{ blob:e.blob, salt:e.salt, kdf:KDF_ID, updatedAt:new Date().toISOString() });
|
|
|
|
|
if(!ur.ok) failed++;
|
|
|
|
|
}
|
|
|
|
|
busy($("acChange"),false);
|
|
|
|
|
$("acOld").value=$("acNew").value=$("acNew2").value="";
|
|
|
|
|
setMsg($("acMsg"), failed
|
|
|
|
|
? "Heslo změněno, ale "+failed+" nastavení zdrojů se nepodařilo přešifrovat — otevři Zdroje a ulož je znovu."
|
|
|
|
|
: "Heslo změněno a zdroje přešifrovány.", failed?"err":"ok");
|
|
|
|
|
await loadAddons();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── boot ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
(async function(){
|
|
|
|
|
fillLangs();
|
2026-08-07 07:33:28 +00:00
|
|
|
$("tvUrl").textContent=location.host;
|
The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 00:10:48 +00:00
|
|
|
if (await restore()) await enter();
|
|
|
|
|
})();
|
2026-07-22 16:23:05 +00:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|