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>
This commit is contained in:
Claude 2026-08-12 18:36:55 +02:00
parent 27ce0f5b60
commit 57585d8ad2
6 changed files with 843 additions and 23 deletions

View file

@ -105,3 +105,89 @@ run in a real browser produced the user, the named profile, and an
with every field intact (adult fields null/false) · `/get/tv` streamed a with every field intact (adult fields null/false) · `/get/tv` streamed a
published dummy APK byte-identical with the attachment header · landing's published dummy APK byte-identical with the attachment header · landing's
download login handled the no-releases state. download login handled the no-releases state.
## The setup flow for someone with their own accounts (2026-08-12)
The family path is the `onboarding_template` route above: one code, and the shared
credentials arrive already filled in. This is the other path, for someone who is
trusted with an account but brings their own subscriptions, and until now had to
work out on their own that Amber needs paid accounts somewhere before it can find
anything.
`#start` is a tab in `index.html`, not a new page, because the deploy copies files
into `pb_public` and never deletes the ones a commit removed. That is why stale
`onboarding.html` and `settings.html` still answer 200 and need shadow routes in
`web.pb.js`, and one file avoids repeating it.
### The six steps
| | Step | Finished when |
|---|---|---|
| 1 | Change the generated password | `onboarding.pwChangedAt` is set |
| 2 | Choose services | `onboarding.chosen` is non-empty |
| 3 | Create accounts and pay | an `addon_config` row exists |
| 4 | Get a TMDB key | the decrypted config has `tmdbKey` |
| 5 | Enter the credentials in Zdroje | an `addon_config` row exists |
| 6 | Download and pair | never; you can always want the app again |
Steps 1, 5 and 6 hand off to the existing Účet, Zdroje and Stáhnout tabs. This
screen owns no credentials and no crypto, deliberately: a second encryptor for one
blob is how a vault gets corrupted. Step 5 in particular is just a signpost to
`adByocBox`, which already turns usernames and keys into addon URLs by calling the
addon's `/encode` straight from the browser.
**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. First, while the vault is still empty, there is nothing to
re-encrypt and no paired television to strand, and the password is still in memory
from the sign-in that just happened.
### State: three fields, and everything else derived
`users.onboarding` is json holding only `skipped`, `chosen` and `pwChangedAt`.
Nothing else needs storing: 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.
`pwChangedAt` is the exception that cannot be derived, because PocketBase records
no password-changed timestamp and `updated` moves for any edit. Absent means "as
far as we know, still on the password the owner generated", which is the honest
default for a reminder.
The flow opens itself only when there is no `addon_config` and `skipped` is not
set, so the family, who all have sources already, never see it. An explicit
`#hash` always wins so bookmarks still land where they say.
### Content lives in `providers`, not in the page
Prices and click-paths go stale on somebody else's schedule, and a wrong price on
our own page reads as a promise. `providers` is editable in the admin UI and
`priceCheckedAt` renders next to the number, so a stale figure looks stale.
`scripts/providers.seed.json` is the starting point; `scripts/seed-providers.py`
upserts by slug and leaves existing rows alone unless given `--force`, so it is
safe to run after any deploy.
`slug` is load-bearing: the page keys its wiring off `torbox`, `prehrajto`,
`webshare` and `tmdb`, and the seeder refuses anything else. Everything else is
free text.
`kind` separates the two questions the flow asks. `source` rows are a choice to
weigh up; `metadata` (TMDB) is not optional and is not offered as one, because
without a key there are no posters and no descriptions.
### Verified locally (2026-08-12)
Against PocketBase 0.39.6, the pinned version, run from a throwaway data dir with
this repo's real `pb_migrations`, `pb_hooks` and `pb_public`. Docker was not used:
its daemon is disabled on the dev box and the binary alone is enough.
Both migrations applied clean on a fresh database. Then, as a locally created test
user with no sources: the flow opened itself; choosing prehraj.to and TorBox
persisted as `{"chosen":["prehrajto","torbox"]}` and nothing else; step 3 rendered
both guides numbered with working links; skipping moved to Profily and raised the
banner; a reload kept it skipped rather than reopening; resuming came back in.
Not yet verified: the whole thing against the live instance, and a real TorBox or
webshare signup followed end to end by someone who does not already know the
answer.

View file

@ -0,0 +1,81 @@
/// <reference path="../pb_data/types.d.ts" />
// The services a BYOC user has to buy, described in data rather than in markup.
//
// **Why a collection and not copy in index.html.** Two of these fields go stale
// on somebody else's schedule: the price, and the click-path through a signup
// form the provider is free to redesign. A wrong price on our own page is worse
// than no price — it reads as a promise — and the fix must not require editing
// HTML and redeploying the backend the family streams through. Everything here
// is editable in the admin UI, and `priceCheckedAt` is rendered next to the
// number so a stale figure is visibly stale instead of quietly wrong.
//
// **Why not onboarding_template.** That collection holds the family's actual
// credentials and is superuser-only on every rule for that reason. Public-facing
// marketing copy has no business sharing a table with secrets, and widening its
// read rule to serve a price list is exactly the kind of accident this comment
// exists to prevent.
//
// **Why `kind`.** TMDB is not a source and is not optional — no key means no
// posters and no descriptions — so the chooser must not offer it as one of three
// things to weigh up. `source` rows are choices; `metadata` rows are steps that
// happen regardless. Keeping it a token rather than a boolean leaves room for a
// third kind without another migration.
//
// **Why `price` is text.** These are quoted in different currencies and
// different periods ("5 $/měsíc", "199 Kč/měsíc", "$30/rok"), and the page only
// ever displays it. A number plus a currency column would be three fields to get
// wrong in exchange for arithmetic nobody does.
//
// Read is limited to signed-in users. There is no public signup — accounts are
// owner-created — so nobody who cannot log in has any use for this, and the
// tighter rule is free. Writing stays superuser-only: this is content the owner
// maintains, not something a user submits.
migrate(
(app) => {
const c = new Collection({
type: "base",
name: "providers",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
// torbox | prehrajto | webshare | tmdb. The page keys its per-service
// wiring off this, so it is the one field that must not be edited
// casually — hence the note here rather than only in the UI.
{ type: "text", name: "slug", required: true, max: 40 },
{ type: "text", name: "name", required: true, max: 80 },
{ type: "url", name: "url", required: true },
{ type: "text", name: "kind", required: true, max: 20 },
// One line: what this unlocks, in the viewer's terms.
{ type: "text", name: "tagline", max: 300 },
{ type: "json", name: "pros", maxSize: 4000 },
{ type: "json", name: "cons", maxSize: 4000 },
{ type: "text", name: "price", max: 120 },
{ type: "text", name: "priceNote", max: 300 },
{ type: "date", name: "priceCheckedAt" },
// Ordered strings, rendered as a numbered list. Plain text on purpose:
// this is read by someone with a signup form open in the next tab, and
// markup would be one more thing to get wrong in an admin textarea.
{ type: "json", name: "steps", maxSize: 20000 },
{ type: "number", name: "sort" },
// A row is hidden rather than deleted when a service stops being worth
// recommending, so its guide survives for anyone who still has it.
{ type: "bool", name: "enabled" },
{ type: "bool", name: "recommended" },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_providers_slug ON providers (slug)",
],
})
app.save(c)
},
(app) => {
app.delete(app.findCollectionByNameOrId("providers"))
},
)

View file

@ -0,0 +1,55 @@
/// <reference path="../pb_data/types.d.ts" />
// Where a user got to in the setup flow — **only the parts that cannot be
// derived.**
//
// Everything else about their progress is already knowable from data that
// exists: whether they have sources at all is a lookup in `addon_config`, which
// the page loads anyway. Storing "sourcesDone" beside it would create a second
// answer to a question that already has one, and the two would eventually
// disagree — at which point the flow either nags someone who is finished or
// congratulates someone who is not.
//
// So this holds three things and no more:
//
// { "skipped": true, // chose "nastavím později"
// "chosen": ["prehrajto", "torbox"], // which services they picked
// "pwChangedAt": "2026-08-12T09:00:00Z" } // they replaced the owner's password
//
// `pwChangedAt` is written by the web page when the change succeeds. It cannot be
// derived: PocketBase records no password-changed timestamp, and `updated` moves
// for any edit at all. Absent therefore means "as far as we know, still on the
// password Richard generated" — which is the honest default for a reminder, and
// the cost of being wrong is one dismissible nudge.
//
// **Per account, not localStorage.** Setup spans devices by nature — the flow is
// read on a phone or laptop while the app is installed on a television — so
// progress kept in one browser would ask someone who finished on their laptop to
// start again on their phone.
//
// Json rather than three columns: it is one opaque blob the page reads and writes
// whole, nothing filters or sorts on it, and adding a fourth thing later should
// not need a migration. `users` update rules are unchanged — a user may already
// PATCH their own record (that is how the rating default is set), and this rides
// on that.
//
// Existing accounts are untouched and read as `{}`: no `skipped`, so the family
// would be offered the flow — except they all have `addon_config` rows already,
// which is what actually suppresses it. Nobody who is set up sees anything.
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users")
users.fields.add(new Field({
type: "json",
name: "onboarding",
maxSize: 4000,
}))
app.save(users)
},
(app) => {
const users = app.findCollectionByNameOrId("users")
users.fields.removeByName("onboarding")
app.save(users)
},
)

View file

@ -65,6 +65,42 @@
.warn { border-left:3px solid var(--amber); padding-left:12px; margin:14px 0; .warn { border-left:3px solid var(--amber); padding-left:12px; margin:14px 0;
font-size:13px; color:var(--muted); line-height:1.5; } font-size:13px; color:var(--muted); line-height:1.5; }
ol.steps { padding-left:20px; margin:0; font-size:14px; line-height:1.7; } ol.steps { padding-left:20px; margin:0; font-size:14px; line-height:1.7; }
/* 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; }
</style> </style>
</head> </head>
<body> <body>
@ -109,8 +145,26 @@
<button id="logoutBtn" class="link" style="margin:0">Odhlásit</button> <button id="logoutBtn" class="link" style="margin:0">Odhlásit</button>
</div> </div>
<!-- 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>
<nav id="nav"></nav> <nav id="nav"></nav>
<!-- 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>
<!-- profiles --> <!-- profiles -->
<section id="tab-profiles" class="card tab hidden"> <section id="tab-profiles" class="card tab hidden">
<h2>Profily</h2> <h2>Profily</h2>
@ -238,8 +292,10 @@
<div><label for="byPtPass">Heslo</label> <div><label for="byPtPass">Heslo</label>
<input id="byPtPass" type="password" autocomplete="off"></div> <input id="byPtPass" type="password" autocomplete="off"></div>
</div> </div>
<p class="hint">Vyplň prehraj.to, webshare, nebo obojí — stačí jeden <p class="hint">Vyplň prehraj.to, webshare, nebo obojí. Stačí jeden
z nich. Obojí najde nejvíc.</p> 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>
<h2 style="margin-top:18px">webshare.cz</h2> <h2 style="margin-top:18px">webshare.cz</h2>
<div class="row"> <div class="row">
@ -427,7 +483,12 @@ async function api(method, path, body, opts){
function saveSession(){ try{ localStorage.setItem(LS, TOKEN||""); }catch(_){} } function saveSession(){ try{ localStorage.setItem(LS, TOKEN||""); }catch(_){} }
function signOut(){ function signOut(){
TOKEN=null; USER=null; PASSWORD=null; cfg=null; cfgRec=null; TOKEN=null; USER=null; PASSWORD=null; cfg=null; cfgRec=null;
// 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=[];
try{ localStorage.removeItem(LS); }catch(_){} try{ localStorage.removeItem(LS); }catch(_){}
$("stBanner").className="warn hidden";
$("app").className="hidden"; $("anon").className=""; $("app").className="hidden"; $("anon").className="";
} }
async function restore(){ async function restore(){
@ -455,8 +516,9 @@ $("loginBtn").onclick=async function(){
$("logoutBtn").onclick=signOut; $("logoutBtn").onclick=signOut;
// ── shell ──────────────────────────────────────────────────────────────────── // ── shell ────────────────────────────────────────────────────────────────────
var TABS=[["profiles","Profily"],["playback","Přehrávání"],["addons","Zdroje"], var TABS=[["start","Začínáme"],["profiles","Profily"],["playback","Přehrávání"],
["devices","Zařízení"],["download","Stáhnout"],["account","Účet"]]; ["addons","Zdroje"],["devices","Zařízení"],["download","Stáhnout"],
["account","Účet"]];
function showTab(id){ function showTab(id){
TABS.forEach(function(t){ TABS.forEach(function(t){
$("tab-"+t[0]).className = "card tab" + (t[0]===id ? "" : " hidden"); $("tab-"+t[0]).className = "card tab" + (t[0]===id ? "" : " hidden");
@ -478,14 +540,311 @@ async function enter(){
$("anon").className="hidden"; $("app").className=""; $("anon").className="hidden"; $("app").className="";
$("whoEmail").textContent=(USER&&USER.email)||""; $("whoEmail").textContent=(USER&&USER.email)||"";
buildNav(); buildNav();
await Promise.all([loadProfiles(), loadDevices(), loadDownloads()]); await Promise.all([loadProfiles(), loadDevices(), loadDownloads(),
loadProviders(), loadConfigList()]);
$("acEmail").value=(USER&&USER.email)||""; $("acEmail").value=(USER&&USER.email)||"";
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted"; $("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
renderAddonGate(); renderAddonGate();
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.
var want=(location.hash||"").replace("#",""); var want=(location.hash||"").replace("#","");
showTab(TABS.some(function(t){return t[0]===want;}) ? want : "profiles"); 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||[]) : [];
}
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;
}
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í.");
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");
};
// ── profiles ───────────────────────────────────────────────────────────────── // ── profiles ─────────────────────────────────────────────────────────────────
var TIERS={ "":"Podle účtu", kids:"Děti (do 7)", twelve:"12+", var TIERS={ "":"Podle účtu", kids:"Děti (do 7)", twelve:"12+",
fifteen:"15+", unrestricted:"Bez omezení" }; fifteen:"15+", unrestricted:"Bez omezení" };
@ -664,6 +1023,9 @@ async function loadAddons(){
} }
} }
$("acChange").disabled = !PASSWORD; $("acChange").disabled = !PASSWORD;
// 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();
} }
$("adFamily").onclick=function(){ $("adSetup").className="hidden"; $("adFamilyBox").className=""; }; $("adFamily").onclick=function(){ $("adSetup").className="hidden"; $("adFamilyBox").className=""; };
$("adByoc").onclick=function(){ $("adSetup").className="hidden"; $("adByocBox").className=""; }; $("adByoc").onclick=function(){ $("adSetup").className="hidden"; $("adByocBox").className=""; };
@ -705,12 +1067,25 @@ $("byGo").onclick=async function(){
setMsg($("adMsg"),"U webshare vyplň jméno i heslo, nebo obojí nech prázdné.","err"); setMsg($("adMsg"),"U webshare vyplň jméno i heslo, nebo obojí nech prázdné.","err");
return; return;
} }
if (!(ptU && ptP) && !(wsU && wsP)){ // **One source of any kind is enough — a Czech host or TorBox.** This used to
setMsg($("adMsg"),"Vyplň aspoň jednoho hosta — prehraj.to nebo webshare.","err"); // 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");
return; return;
} }
busy($("byGo"),true,"Nastavuji…"); setMsg($("adMsg"),""); busy($("byGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
var out={ tmdbKey:tmdb }; var out={ tmdbKey:tmdb };
// 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 { try {
// Straight to the addon. Both send permissive CORS, so the credentials go // Straight to the addon. Both send permissive CORS, so the credentials go
// from this browser to the addon and never through our server. // from this browser to the addon and never through our server.
@ -726,6 +1101,7 @@ $("byGo").onclick=async function(){
setMsg($("adMsg"),"Český doplněk odmítl údaje: "+err.message,"err"); setMsg($("adMsg"),"Český doplněk odmítl údaje: "+err.message,"err");
return; return;
} }
}
if (tb){ if (tb){
// The TorBox addon has no /encode — its config token is base64url of the // The TorBox addon has no /encode — its config token is base64url of the
// same JSON its own configure page builds. // same JSON its own configure page builds.
@ -865,7 +1241,15 @@ $("acChange").onclick=async function(){
// The password change invalidates the token; re-authenticate before writing. // The password change invalidates the token; re-authenticate before writing.
var a=await api("POST","/api/collections/users/auth-with-password", var a=await api("POST","/api/collections/users/auth-with-password",
{ identity:USER.email, password:n1 }, {anon:true}); { identity:USER.email, password:n1 }, {anon:true});
if (a.ok){ TOKEN=a.data.token; USER=a.data.record; PASSWORD=n1; saveSession(); } 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() });
}
var failed=0; var failed=0;
for (var j=0;j<reEncrypted.length;j++){ for (var j=0;j<reEncrypted.length;j++){
var e=reEncrypted[j]; var e=reEncrypted[j];

125
scripts/providers.seed.json Normal file
View file

@ -0,0 +1,125 @@
[
{
"slug": "prehrajto",
"name": "prehraj.to",
"url": "https://prehrajto.cz/",
"kind": "source",
"recommended": true,
"enabled": true,
"sort": 10,
"tagline": "Český dabing. Tady Amber hledá dabované filmy a seriály.",
"price": "159 Kč/měsíc",
"priceNote": "Prvních 30 dní máš premium zdarma, kartu zadáváš až potom.",
"priceCheckedAt": "2026-08-12 00:00:00.000Z",
"pros": [
"Nejvíc českých dabingů. Kvůli tomu Amber vlastně vznikl.",
"Amber si u každého souboru ověří, že dabing skutečně obsahuje. Názvu nevěří."
],
"cons": [
"Platí se každý měsíc.",
"Katalog je jen to, co tam někdo nahrál. U starších seriálů občas chybí celé řady."
],
"steps": [
"Otevři prehrajto.cz.",
"Dej „Registrovat“ a založ si účet, pokud ho ještě nemáš.",
"Tím ti začalo 30 dní premium zdarma. Můžeš se rovnou vrátit do Amberu a nic neplatit.",
"Až těch 30 dní vyprší, klikni na „Můj účet“ a pak na „Koupit premium“.",
"Vyber tarif. Měsíční je za 159 Kč.",
"Zadej údaje z karty a dokonči platbu. Premium se aktivuje hned."
]
},
{
"slug": "webshare",
"name": "webshare.cz",
"url": "https://webshare.cz/",
"kind": "source",
"recommended": false,
"enabled": true,
"sort": 20,
"tagline": "Druhý český host. Funguje i sám o sobě, spolu s prehraj.to najdeš nejvíc.",
"price": "89 Kč/měsíc",
"priceNote": "Nejlevnější způsob, jak mít české dabingy.",
"priceCheckedAt": "2026-08-12 00:00:00.000Z",
"pros": [
"Nejlevnější vstup do českých dabingů.",
"Funguje i bez prehraj.to, stačí jeden z nich.",
"Najde dabingy, které na prehraj.to nejsou."
],
"cons": [
"Neposkytuje délku souboru, takže u něj Amber hůř pozná, jestli je to skutečně ten film, který jsi chtěl.",
"Platí se každý měsíc."
],
"steps": [
"Otevři webshare.cz.",
"Klikni na „Aktivovat VIP za 89 Kč/měsíc“.",
"Projdi tím, čím tě webshare provede: registrace, potvrzení e-mailu, aktivace VIP.",
"Po aktivaci se vrať do Amberu. Bez VIP se soubory spolehlivě nepřehrají."
]
},
{
"slug": "torbox",
"name": "TorBox",
"url": "https://torbox.app/pricing",
"kind": "source",
"recommended": true,
"enabled": true,
"sort": 30,
"tagline": "Největší výběr. Pro anime, originální znění, novinky a 4K.",
"price": "3 $/měsíc (Essential)",
"priceNote": "Tarif Free s Amberem nefunguje, protože nemá API přístup. Essential je nejnižší, který funguje.",
"priceCheckedAt": "2026-08-12 00:00:00.000Z",
"pros": [
"Zdaleka největší výběr ze všech zdrojů.",
"Bez něj nemá anime co přehrávat.",
"Pro originální znění, novinky, seriály a 4K je nejlepší."
],
"cons": [
"České dabingy tu skoro nejsou. Bez českého hosta nebude co pustit dětem.",
"Platí se v dolarech."
],
"steps": [
"Otevři torbox.app/pricing.",
"Vyber tarif. Essential za 3 $ je nejnižší, který s Amberem funguje, protože Free nemá API přístup.",
"Přesměruje tě to na přihlašovací stránku. Úplně dole klikni na „SIGN UP“.",
"Zadej e-mail a vymysli heslo. Ulož si ho někam, budeš ho potřebovat.",
"Zaškrtni „I agree with the terms and conditions“ a dej „Continue“.",
"Potvrď e-mail. Od TorBoxu ti přijde odkaz, klikni na něj.",
"Skončíš v TorBox Dashboardu na torbox.app/dashboard.",
"Vlevo dole klikni na „Upgrade Now“.",
"Vyber, čím chceš zaplatit, a dokonči platbu podle instrukcí.",
"Po zaplacení klikni vpravo nahoře na svůj profil a dej „Settings“.",
"Sjeď dolů, najdi „Copy API Key“ a klikni na něj.",
"Klíč máš ve schránce. Vrať se do Amberu a vlož ho, neopisuj ho po znacích."
]
},
{
"slug": "tmdb",
"name": "TMDB",
"url": "https://www.themoviedb.org/signup",
"kind": "metadata",
"recommended": false,
"enabled": true,
"sort": 40,
"tagline": "Plakáty, popisy a obsazení. Zdarma, ale musíš si o klíč říct.",
"price": "zdarma",
"priceNote": "",
"priceCheckedAt": "2026-08-12 00:00:00.000Z",
"pros": [
"Zdarma.",
"Bez klíče Amber funguje, ale místo plakátů a popisů uvidíš prázdno."
],
"cons": [
"Musíš si založit účet a vyplnit krátký formulář."
],
"steps": [
"Udělej to na počítači, ne na mobilu. TMDB sám píše, že registrace klíče pro mobil uzpůsobená není.",
"Otevři themoviedb.org/signup a založ si účet. Je to zdarma.",
"Potvrď e-mail, TMDB ti pošle odkaz.",
"Přihlas se, klikni vpravo nahoře na svůj avatar a dej „Settings“.",
"V levém menu vyber „API“.",
"U „Request an API Key“ vyber „Developer“ a odsouhlas podmínky.",
"Vyplň formulář. Jako typ použití dej osobní nebo nekomerční, jako název aplikace klidně „Amber“ a do popisu jednu větu, že jde o soukromý přehrávač pro rodinu.",
"Zkopíruj „API Key (v3 auth)“, ten krátký o 32 znacích. Pozor, ne „API Read Access Token“, který je dlouhý a začíná na „ey“. Ten Amber nepoužívá."
]
}
]

89
scripts/seed-providers.py Normal file
View file

@ -0,0 +1,89 @@
"""Load (or refresh) the `providers` rows the setup flow renders.
python scripts/seed-providers.py # local docker compose
PB_BASE=https://amber.petruzalekr.cz \
PB_ADMIN_EMAIL= PB_ADMIN_PASS= python scripts/seed-providers.py
Reads `scripts/providers.seed.json` and **upserts by `slug`**, so running it
twice is the same as running it once. That matters more than it sounds: prices
and click-paths are edited in the admin UI, and a seeder that inserted blindly
would either duplicate every row or quietly overwrite a correction somebody made
last week. `--force` is the switch for "yes, put the file back over the top".
Without `--force` an existing row is left alone and reported as `kept`, which
makes this safe to run after any deploy the collection gets populated on a
fresh instance and untouched on one that is already looked after.
Never reads secrets/amber.env: the superuser credentials come from the
environment or from the local defaults, the same way scripts/verify.py takes
them, so nothing here needs access to the family's credential file.
"""
import json, os, pathlib, sys, urllib.error, urllib.request
BASE = os.environ.get("PB_BASE", "http://localhost:8090").rstrip("/")
ADMIN_EMAIL = os.environ.get("PB_ADMIN_EMAIL", "admin@myanime.local")
ADMIN_PASS = os.environ.get("PB_ADMIN_PASS", "Sup3rSecret!123")
FORCE = "--force" in sys.argv
SEED = pathlib.Path(__file__).with_name("providers.seed.json")
def req(method, path, token=None, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method)
r.add_header("Content-Type", "application/json")
if token:
r.add_header("Authorization", token)
try:
with urllib.request.urlopen(r) as resp:
return resp.status, json.loads(resp.read() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "{}")
def main():
rows = json.loads(SEED.read_text(encoding="utf-8"))
slugs = [r["slug"] for r in rows]
if len(set(slugs)) != len(slugs):
sys.exit(f"duplicate slug in {SEED.name}: {slugs}")
# The page keys its per-service wiring off these four strings; a typo here
# would render a card that leads nowhere, so it is checked rather than
# trusted.
known = {"torbox", "prehrajto", "webshare", "tmdb"}
unknown = set(slugs) - known
if unknown:
sys.exit(f"unknown slug(s) {sorted(unknown)} — the page has no wiring for these")
st, auth = req("POST", "/api/collections/_superusers/auth-with-password",
body={"identity": ADMIN_EMAIL, "password": ADMIN_PASS})
if st != 200:
sys.exit(f"superuser auth failed against {BASE}: HTTP {st} {auth}")
token = auth["token"]
st, existing = req("GET", "/api/collections/providers/records?perPage=200", token)
if st != 200:
sys.exit(f"cannot list providers (is the migration applied?): HTTP {st} {existing}")
by_slug = {i["slug"]: i for i in existing.get("items", [])}
for row in rows:
slug = row["slug"]
cur = by_slug.get(slug)
if cur and not FORCE:
print(f"[kept] {slug} — already present, --force to overwrite")
continue
if cur:
st, res = req("PATCH", f"/api/collections/providers/records/{cur['id']}",
token, row)
verb = "updated"
else:
st, res = req("POST", "/api/collections/providers/records", token, row)
verb = "created"
if st not in (200, 201):
sys.exit(f"[FAIL] {slug}: HTTP {st} {res}")
print(f"[{verb}] {slug}")
print(f"\n{len(rows)} provider(s) in {BASE}. "
"Prices are editable in the admin UI — this file is only the starting point.")
if __name__ == "__main__":
main()