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:
parent
27ce0f5b60
commit
57585d8ad2
6 changed files with 843 additions and 23 deletions
|
|
@ -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
|
||||
published dummy APK byte-identical with the attachment header · landing's
|
||||
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.
|
||||
|
|
|
|||
81
pb_migrations/1790500000_providers.js
Normal file
81
pb_migrations/1790500000_providers.js
Normal 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"))
|
||||
},
|
||||
)
|
||||
55
pb_migrations/1790500001_users_onboarding.js
Normal file
55
pb_migrations/1790500001_users_onboarding.js
Normal 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)
|
||||
},
|
||||
)
|
||||
|
|
@ -65,6 +65,42 @@
|
|||
.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; }
|
||||
/* 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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -109,8 +145,26 @@
|
|||
<button id="logoutBtn" class="link" style="margin:0">Odhlásit</button>
|
||||
</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>
|
||||
|
||||
<!-- 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 -->
|
||||
<section id="tab-profiles" class="card tab hidden">
|
||||
<h2>Profily</h2>
|
||||
|
|
@ -238,8 +292,10 @@
|
|||
<div><label for="byPtPass">Heslo</label>
|
||||
<input id="byPtPass" type="password" autocomplete="off"></div>
|
||||
</div>
|
||||
<p class="hint">Vyplň prehraj.to, webshare, nebo obojí — stačí jeden
|
||||
z nich. Obojí najde nejvíc.</p>
|
||||
<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>
|
||||
|
||||
<h2 style="margin-top:18px">webshare.cz</h2>
|
||||
<div class="row">
|
||||
|
|
@ -427,7 +483,12 @@ async function api(method, path, body, opts){
|
|||
function saveSession(){ try{ localStorage.setItem(LS, TOKEN||""); }catch(_){} }
|
||||
function signOut(){
|
||||
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(_){}
|
||||
$("stBanner").className="warn hidden";
|
||||
$("app").className="hidden"; $("anon").className="";
|
||||
}
|
||||
async function restore(){
|
||||
|
|
@ -455,8 +516,9 @@ $("loginBtn").onclick=async function(){
|
|||
$("logoutBtn").onclick=signOut;
|
||||
|
||||
// ── shell ────────────────────────────────────────────────────────────────────
|
||||
var TABS=[["profiles","Profily"],["playback","Přehrávání"],["addons","Zdroje"],
|
||||
["devices","Zařízení"],["download","Stáhnout"],["account","Účet"]];
|
||||
var TABS=[["start","Začínáme"],["profiles","Profily"],["playback","Přehrávání"],
|
||||
["addons","Zdroje"],["devices","Zařízení"],["download","Stáhnout"],
|
||||
["account","Účet"]];
|
||||
function showTab(id){
|
||||
TABS.forEach(function(t){
|
||||
$("tab-"+t[0]).className = "card tab" + (t[0]===id ? "" : " hidden");
|
||||
|
|
@ -478,14 +540,311 @@ async function enter(){
|
|||
$("anon").className="hidden"; $("app").className="";
|
||||
$("whoEmail").textContent=(USER&&USER.email)||"";
|
||||
buildNav();
|
||||
await Promise.all([loadProfiles(), loadDevices(), loadDownloads()]);
|
||||
await Promise.all([loadProfiles(), loadDevices(), loadDownloads(),
|
||||
loadProviders(), loadConfigList()]);
|
||||
$("acEmail").value=(USER&&USER.email)||"";
|
||||
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
|
||||
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("#","");
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
var TIERS={ "":"Podle účtu", kids:"Děti (do 7)", twelve:"12+",
|
||||
fifteen:"15+", unrestricted:"Bez omezení" };
|
||||
|
|
@ -664,6 +1023,9 @@ async function loadAddons(){
|
|||
}
|
||||
}
|
||||
$("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=""; };
|
||||
$("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");
|
||||
return;
|
||||
}
|
||||
if (!(ptU && ptP) && !(wsU && wsP)){
|
||||
setMsg($("adMsg"),"Vyplň aspoň jednoho hosta — prehraj.to nebo webshare.","err");
|
||||
// **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");
|
||||
return;
|
||||
}
|
||||
busy($("byGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
||||
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 {
|
||||
// Straight to the addon. Both send permissive CORS, so the credentials go
|
||||
// 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");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (tb){
|
||||
// The TorBox addon has no /encode — its config token is base64url of the
|
||||
// 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.
|
||||
var a=await api("POST","/api/collections/users/auth-with-password",
|
||||
{ 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;
|
||||
for (var j=0;j<reEncrypted.length;j++){
|
||||
var e=reEncrypted[j];
|
||||
|
|
|
|||
125
scripts/providers.seed.json
Normal file
125
scripts/providers.seed.json
Normal 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
89
scripts/seed-providers.py
Normal 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()
|
||||
Loading…
Reference in a new issue