Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b5fe2e31 | ||
|
|
d260aabfd7 | ||
|
|
d832bb19ce | ||
|
|
59efc8f2e8 | ||
|
|
e4ffd59ca7 | ||
|
|
3709b71a94 | ||
|
|
cedebe0ffb | ||
|
|
fdc1eabbe0 | ||
|
|
84f4b20e5a | ||
|
|
828dad015c | ||
|
|
82914b56ca | ||
|
|
ccd5f3739a | ||
|
|
f3c0b7be23 | ||
|
|
53b50231ff | ||
|
|
5703c4d713 | ||
|
|
013f19faca | ||
|
|
8d9a99ce54 | ||
|
|
45dd4bdda7 | ||
|
|
dac391a890 | ||
|
|
4b33036e7c | ||
|
|
80dea4f307 | ||
|
|
20e17c0ccb | ||
|
|
7d302f71b7 | ||
|
|
f99240d8ca | ||
|
|
cc8cf96c19 | ||
|
|
86163958d6 | ||
|
|
12822a9e04 | ||
|
|
928eac4c89 | ||
|
|
b28be682fe | ||
|
|
7e97559615 | ||
|
|
721467ef69 | ||
|
|
47e2293de3 | ||
|
|
29bb914a95 | ||
|
|
5bfcf5d83a | ||
|
|
31fa554b87 | ||
|
|
6a0efba477 | ||
|
|
9f28f46839 | ||
|
|
14f1c97c0a | ||
|
|
fc56f514b3 | ||
|
|
358d722ce9 | ||
|
|
fe38dfbcca | ||
|
|
f98b060e2b | ||
|
|
bc9e34d0ae | ||
|
|
0cc450d1c9 | ||
|
|
688efb9ed6 | ||
|
|
ca4e559cc7 |
+1838
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,8 @@ The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc project
|
||||
keep commits logical and Conventional-Commit-styled.
|
||||
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
||||
being AI-written is the intended, visible exception; commits are not.)
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
|
||||
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides _when_ to ship; on their
|
||||
go-ahead _you_ run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
|
||||
go-ahead first, then run it yourself.
|
||||
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
||||
@@ -41,7 +41,7 @@ how it gets built. Concretely:
|
||||
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
|
||||
work) and get a go-ahead before writing code.
|
||||
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
|
||||
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
|
||||
list them and mark the recommended one. This is the _one_ place to spend extra words: give the _why_
|
||||
behind each option and the weight of the decision. These decision points are where the product owner
|
||||
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||
@@ -59,31 +59,38 @@ how it gets built. Concretely:
|
||||
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
||||
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → components; data
|
||||
via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through `lib/api/*`
|
||||
slices over `core.req<T>`.
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
|
||||
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
||||
`lib/api/*` slices over `core.req<T>`.
|
||||
|
||||
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
||||
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
||||
|
||||
## Canonical patterns — when you have a choice, pick THIS
|
||||
|
||||
| Area | Use | Not |
|
||||
|---|---|---|
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| Area | Use | Not |
|
||||
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
@@ -100,6 +107,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
|
||||
---
|
||||
|
||||
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
_Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above._
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -94,9 +94,18 @@ docker compose -f docker-compose.selfhost.yml pull # or: docker compose pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Database migrations run automatically on startup. Your data (accounts, subscriptions, playlists, the
|
||||
video catalog) lives in a Postgres volume — back it up with `scripts/backup.sh` (or `backup.ps1` on
|
||||
Windows) and restore with `scripts/restore.sh`.
|
||||
That follows `latest`, which moves with every release. Set `IMAGE_TAG=0.80.0` in `.env` to pin a
|
||||
version and upgrade on your own schedule; released versions are listed in [`CHANGELOG.md`](CHANGELOG.md)
|
||||
and in the app's Release Notes. The registry keeps only the newest few of those, so a pin decides
|
||||
*when* you take the next hop rather than letting you stay put forever — see
|
||||
[docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
**Back up before upgrading.** Migrations run automatically on startup and are not undone by pulling
|
||||
the old image back. A backup is the database dump **plus your `.env`** — `TOKEN_ENCRYPTION_KEY` is
|
||||
what decrypts the Google, SMTP and Plex secrets stored in that database, so a dump restored without it
|
||||
is only half an instance. Use `scripts/backup.sh` (or `backup.ps1` on Windows) and `scripts/restore.sh`;
|
||||
the equivalent one-liners, the pinning recipe and the rollback steps are in
|
||||
[docs/self-hosting.md](docs/self-hosting.md#backups).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -111,6 +120,12 @@ Windows) and restore with `scripts/restore.sh`.
|
||||
FastAPI + PostgreSQL (SQLAlchemy, Alembic) backend; React + Vite + Tailwind + TanStack Query
|
||||
frontend; packaged as a single Docker image with Docker Compose.
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL-3.0](LICENSE). You can run, study, modify and share Siftlode; if you modify it and let other
|
||||
people use it over a network, they are entitled to your modified source. Copyright © 2026 Siftlode
|
||||
contributors.
|
||||
|
||||
## Note
|
||||
|
||||
This project is developed with AI assistance.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""comfort_pins — the shelf you fall back to when you cannot decide
|
||||
|
||||
Revision ID: 0083_comfort_pins
|
||||
Revises: 0082_invite_lang
|
||||
Create Date: 2026-09-06
|
||||
|
||||
E24's one new table. The shelf holds five different things — a video, a YouTube channel, a Plex show,
|
||||
a Plex movie, a playlist — because comfort media is usually "put on X", a series or a channel, and
|
||||
almost never one particular video.
|
||||
|
||||
Three shapes were possible and two are wrong here:
|
||||
|
||||
- a boolean on `video_states` / `plex_states` cannot ORDER a shelf, and has nowhere to put a channel
|
||||
or a show (neither has a per-user state row at all);
|
||||
- a polymorphic `(kind, ref)` string pair orders and holds anything, but drops the foreign key — and a
|
||||
Plex item removed from the library, or a channel the user unsubscribes and prunes, would then leave
|
||||
a dead tile on the one surface whose entire job is to be reliable when nothing else is.
|
||||
|
||||
So: one nullable CASCADE-ing FK per kind, plus `num_nonnulls(...) = 1`. The union invariant lives in
|
||||
the schema rather than in a comment at every insert site, which is the same rule that the
|
||||
`cancelled`/`canceled` bug taught this codebase.
|
||||
|
||||
NULLs are DISTINCT in a Postgres unique index, so the five per-kind `(user_id, <ref>)` uniques each
|
||||
stop a double-pin without colliding with the other four kinds' NULL rows.
|
||||
|
||||
No backfill: an empty shelf is the honest state for every existing account. The surface renders its
|
||||
own empty state and invites the first pin.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0083_comfort_pins"
|
||||
down_revision: str | None = "0082_invite_lang"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"comfort_pins",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("video_id", sa.String(length=16), sa.ForeignKey("videos.id", ondelete="CASCADE")),
|
||||
sa.Column(
|
||||
"channel_id", sa.String(length=32), sa.ForeignKey("channels.id", ondelete="CASCADE")
|
||||
),
|
||||
sa.Column("plex_item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE")),
|
||||
sa.Column("plex_show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE")),
|
||||
sa.Column("playlist_id", sa.Integer(), sa.ForeignKey("playlists.id", ondelete="CASCADE")),
|
||||
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1",
|
||||
name="ck_comfort_pin_one_ref",
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"),
|
||||
sa.UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"),
|
||||
sa.UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"),
|
||||
sa.UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"),
|
||||
sa.UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"),
|
||||
)
|
||||
op.create_index("ix_comfort_pins_user_id", "comfort_pins", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_comfort_pins_user_id", table_name="comfort_pins")
|
||||
op.drop_table("comfort_pins")
|
||||
@@ -31,6 +31,7 @@ class AuditAction:
|
||||
# recorded because both are the admin acting ON someone's access, not merely reading a list.
|
||||
USER_ACTIVATE = "user_activate"
|
||||
USER_VERIFY_RESEND = "user_verify_resend"
|
||||
USER_SETPW_LINK = "user_setpw_link"
|
||||
INVITE_APPROVE = "invite_approve"
|
||||
INVITE_DENY = "invite_deny"
|
||||
INVITE_ADD = "invite_add"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""E24 — the read-model behind the Home surface ("Mit nézzek most?").
|
||||
|
||||
The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own
|
||||
comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule
|
||||
the Plex Discover surface follows).
|
||||
|
||||
Layering, mirroring `app/plex/`: `budget` (the time bands) and `cards` (the unified row shape) are
|
||||
pure; `queries` holds the SQL; `rails`, `pins` and `hero` compose. Nothing here imports from
|
||||
`app.routes` — `routes/discover.py` is a thin HTTP shell over these.
|
||||
"""
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The time budget — E24's replacement for "what mood are you in?".
|
||||
|
||||
`random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's
|
||||
later BA-interview note (#23) says the mood half is not what makes this work: the friction is
|
||||
deciding at all. A mood→genre mapping would also be invented data — we store no mood signal and never
|
||||
will — whereas "how much time do you have" is a question the library can actually answer, from a
|
||||
column we already have.
|
||||
|
||||
So the surface asks about TIME, and the answer is deterministic: four bands, no model, no latency.
|
||||
The band also picks the source for free — ten minutes is a YouTube video, an evening is a movie —
|
||||
which is exactly the cross-source behaviour the Home surface exists to provide.
|
||||
|
||||
The bands overlap on purpose. "I have 30 minutes" should not be answered with a two-minute Short just
|
||||
because it fits; a lower bound is as much a part of the promise as the upper one.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
Budget = Literal["short", "medium", "long", "any"]
|
||||
|
||||
DEFAULT_BUDGET: Budget = "any"
|
||||
|
||||
# budget -> (min_seconds, max_seconds); None = unbounded on that side.
|
||||
BANDS: dict[str, tuple[int | None, int | None]] = {
|
||||
"short": (None, 12 * 60), # "10 perc" — a bit of slack over the round number the chip shows
|
||||
"medium": (8 * 60, 40 * 60), # "30 perc"
|
||||
"long": (40 * 60, None), # "Egy este"
|
||||
"any": (None, None), # "Mindegy"
|
||||
}
|
||||
|
||||
|
||||
def band(budget: str) -> tuple[int | None, int | None]:
|
||||
"""The (min, max) seconds for a budget. Unknown budgets fall back to the unbounded band rather
|
||||
than raising: the route validates the vocabulary with a Literal, so anything reaching here with a
|
||||
bad value is our own bug, and an unfiltered shelf is a better failure than a 500 on the landing
|
||||
page."""
|
||||
return BANDS.get(budget, BANDS["any"])
|
||||
|
||||
|
||||
def remaining_seconds(duration_seconds: int | None, position_seconds: int) -> int | None:
|
||||
"""What is actually LEFT to watch. The question is "how much time do you have", so a 90-minute
|
||||
film with 8 minutes to go belongs in the ten-minute band, not the evening one. Unknown duration
|
||||
stays unknown — see `fits`."""
|
||||
if duration_seconds is None:
|
||||
return None
|
||||
return max(0, duration_seconds - max(0, position_seconds))
|
||||
|
||||
|
||||
def fits(seconds: int | None, budget: str) -> bool:
|
||||
"""Does a runtime fit the budget?
|
||||
|
||||
An unknown duration fits only the unbounded band. We cannot promise an item finishes inside ten
|
||||
minutes when we do not know how long it is, and quietly including it would make the one control
|
||||
on this page dishonest — the failure mode the house rules call an un-honest state."""
|
||||
lo, hi = band(budget)
|
||||
if seconds is None:
|
||||
return lo is None and hi is None
|
||||
if lo is not None and seconds < lo:
|
||||
return False
|
||||
if hi is not None and seconds > hi:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The unified discover card: one row shape the Home surface can render without knowing which
|
||||
module it came from.
|
||||
|
||||
This is deliberately a THIN mapping, not a new serializer layer. The per-source projections already
|
||||
exist and are already the shapes the frontend's `VideoCard` and `PlexPosterCard` consume —
|
||||
`feedquery.serialize` for YouTube, `plex.cards.movie_card`/`show_card`/`leaf_card` for Plex — so each
|
||||
unified card carries the native payload under `video` / `plex` alongside the handful of fields the
|
||||
rail chrome itself needs (title, thumbnail, progress, runtime). The Home page therefore renders with
|
||||
the existing card components rather than a third one, and a field added to a feed row or a Plex card
|
||||
still gets added in exactly one place. CLAUDE.md's "not wanted" list names a base serializer for
|
||||
precisely this reason.
|
||||
|
||||
`aspect` is the one genuinely new field. A rail has to know whether a tile is a 16:9 still or a 2:3
|
||||
poster, and that is a property of the ARTWORK, not of the module: a Plex episode ships a landscape
|
||||
still exactly like a YouTube video does, while a movie and a show ship posters. Emitting it lets the
|
||||
rail layer stay domain-free instead of branching on `source`.
|
||||
|
||||
`subtitle` only ever carries DATA — a channel name, a show title, a year. Nothing here composes an
|
||||
English phrase: this repo is HU+EN with zero key drift, and a sentence assembled in the backend is a
|
||||
string no translation file can reach. Anything that needs wording (season counts, "N videó") is left
|
||||
to the client, which has both the native payload and the translations.
|
||||
"""
|
||||
|
||||
from app.discover.budget import remaining_seconds
|
||||
from app.models import Channel, Playlist
|
||||
|
||||
# Plex artwork is a poster for the things you browse and a landscape still for the things you play.
|
||||
_POSTER_KINDS = ("movie", "show")
|
||||
|
||||
|
||||
def _base(
|
||||
*,
|
||||
source: str, # "youtube" | "plex"
|
||||
kind: str, # "video" | "movie" | "show" | "episode" | "channel" | "playlist"
|
||||
id: str,
|
||||
title: str | None,
|
||||
aspect: str, # "wide" (16:9) | "poster" (2:3)
|
||||
subtitle: str | None = None,
|
||||
thumb: str | None = None,
|
||||
duration_seconds: int | None = None,
|
||||
position_seconds: int = 0,
|
||||
status: str = "new",
|
||||
) -> dict:
|
||||
return {
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"id": id,
|
||||
"title": title or "",
|
||||
"subtitle": subtitle,
|
||||
"thumb": thumb,
|
||||
"aspect": aspect,
|
||||
"duration_seconds": duration_seconds,
|
||||
"position_seconds": position_seconds,
|
||||
"remaining_seconds": remaining_seconds(duration_seconds, position_seconds),
|
||||
"status": status,
|
||||
# Native payloads — at most one is ever set. The client opens the player from these.
|
||||
"video": None,
|
||||
"plex": None,
|
||||
}
|
||||
|
||||
|
||||
def key(card: dict) -> str:
|
||||
"""The identity of a card ACROSS sources — `<source>:<id>`. A Plex rating_key and a YouTube video
|
||||
id could in principle collide as bare strings, and the reroll's exclusion list has to be able to
|
||||
say "not that one" about either."""
|
||||
return f"{card['source']}:{card['id']}"
|
||||
|
||||
|
||||
def from_video(row: dict) -> dict:
|
||||
"""`row` is a `feedquery.serialize` dict — the same object the feed grid renders."""
|
||||
card = _base(
|
||||
source="youtube",
|
||||
kind="video",
|
||||
id=row["id"],
|
||||
title=row["title"],
|
||||
aspect="wide",
|
||||
subtitle=row.get("channel_title"),
|
||||
thumb=row.get("thumbnail_url"),
|
||||
duration_seconds=row.get("duration_seconds"),
|
||||
position_seconds=row.get("position_seconds") or 0,
|
||||
status=row.get("status") or "new",
|
||||
)
|
||||
card["video"] = row
|
||||
return card
|
||||
|
||||
|
||||
def from_plex(row: dict) -> dict:
|
||||
"""`row` is a `plex.cards` dict (movie / show / leaf). Its own `type` is the kind."""
|
||||
kind = row.get("type") or "movie"
|
||||
if kind == "episode":
|
||||
subtitle = row.get("show_title")
|
||||
elif kind == "show":
|
||||
subtitle = None # season count lives in the native payload; the client words it
|
||||
else:
|
||||
subtitle = str(row["year"]) if row.get("year") else None
|
||||
|
||||
card = _base(
|
||||
source="plex",
|
||||
kind=kind,
|
||||
id=row["id"],
|
||||
title=row.get("title"),
|
||||
aspect="poster" if kind in _POSTER_KINDS else "wide",
|
||||
subtitle=subtitle,
|
||||
thumb=row.get("thumb"),
|
||||
duration_seconds=row.get("duration_seconds"),
|
||||
position_seconds=row.get("position_seconds") or 0,
|
||||
status=row.get("status") or "new",
|
||||
)
|
||||
card["plex"] = row
|
||||
return card
|
||||
|
||||
|
||||
def from_channel(ch: Channel, unwatched: int) -> dict:
|
||||
"""A pinned channel. It is not itself playable — the shelf resolves it to its newest unwatched
|
||||
video on click (see `pins._shelf_card`) — so the tile advertises what is waiting there instead of a
|
||||
runtime it does not have."""
|
||||
card = _base(
|
||||
source="youtube",
|
||||
kind="channel",
|
||||
id=ch.id,
|
||||
title=ch.title,
|
||||
aspect="wide",
|
||||
thumb=ch.thumbnail_url,
|
||||
)
|
||||
card["unwatched_count"] = unwatched
|
||||
return card
|
||||
|
||||
|
||||
def from_playlist(pl: Playlist, item_count: int, thumb: str | None) -> dict:
|
||||
"""A pinned playlist. `thumb` is its first item's thumbnail — a shelf of blank tiles would defeat
|
||||
the point of a surface you are supposed to be able to scan at a glance."""
|
||||
card = _base(
|
||||
source="youtube",
|
||||
kind="playlist",
|
||||
id=str(pl.id),
|
||||
title=pl.name,
|
||||
aspect="wide",
|
||||
thumb=thumb,
|
||||
)
|
||||
card["item_count"] = item_count
|
||||
return card
|
||||
@@ -0,0 +1,104 @@
|
||||
"""The hero: the single suggestion the Home surface leads with.
|
||||
|
||||
One rule, in one function, because the page's entire promise is that it answers rather than offers.
|
||||
Three tiers, tried in order:
|
||||
|
||||
1. **resume** — something already started whose REMAINING time fits the budget. Almost always the
|
||||
right answer: it is the thing the user themselves chose most recently, and finishing it costs no
|
||||
decision at all.
|
||||
2. **comfort** — a pinned shelf entry that fits. The user's own declared fallback, which beats
|
||||
anything we could infer.
|
||||
3. **surprise** — a seeded draw from what is unseen and fits.
|
||||
|
||||
That order is the PAGE's answer. "Mást kérek" rotates it (`start`), so each press offers a different
|
||||
KIND of answer rather than the next row of the same list — the user's call, 2026-09-06, after the
|
||||
button was measured cycling nine resume cards and never once reaching the shelf.
|
||||
|
||||
The tier travels with the card so the client can label it honestly ("Ott hagytad abba" vs "A
|
||||
polcodról" vs "Meglepetés") instead of guessing from the shape of the payload — a kicker that says
|
||||
"where you left off" over a film the user has never opened is exactly the kind of quiet lie the house
|
||||
rules call a dishonest state.
|
||||
|
||||
Budgets are matched against REMAINING time throughout (`budget.remaining_seconds`, which equals the
|
||||
runtime for anything not yet started): with eight minutes left, a 90-minute film is a ten-minute
|
||||
answer.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.discover import cards, pins, rails
|
||||
from app.discover.budget import fits
|
||||
from app.models import User
|
||||
|
||||
HERO_TIERS = ("resume", "comfort", "surprise")
|
||||
|
||||
# How deep the surprise tier draws before giving up. Deliberately deeper than one, so an excluded
|
||||
# pick moves the tier along instead of emptying it (the shuffle is seeded — the same seed always
|
||||
# hands back the same order, so without the headroom "already shown" would mean "nothing left").
|
||||
SURPRISE_DRAW = 12
|
||||
|
||||
|
||||
def pick(
|
||||
db: Session,
|
||||
user: User,
|
||||
budget: str,
|
||||
seed: int,
|
||||
*,
|
||||
resume: list[dict] | None = None,
|
||||
shelf: list[dict] | None = None,
|
||||
exclude: set[str] | None = None,
|
||||
start: int = 0,
|
||||
) -> dict | None:
|
||||
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits.
|
||||
|
||||
`resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does
|
||||
not build the same two rails twice; the reroll endpoint passes neither and pays only for what it
|
||||
reaches.
|
||||
|
||||
`exclude` is what makes "Mást kérek" mean something. A new seed alone only re-draws the SURPRISE
|
||||
tier: the resume tier deterministically returns the most recently progressed thing that fits, so
|
||||
pressing reroll on a user who has something half-finished handed back the identical card and the
|
||||
button looked dead (measured in the browser, 2026-09-06). Skipping the cards already shown makes
|
||||
every press produce a different answer, at every tier.
|
||||
|
||||
`start` ROTATES which tier is tried first — 0 keeps the priority order (the page's own first
|
||||
answer), 1 leads with comfort, 2 with surprise. Exclusion alone was not enough: the resume rail
|
||||
holds up to 20 rows, so the button walked down it and never reached the shelf or a surprise at
|
||||
all (measured, 2026-09-06: twelve presses returned nine cards, every one of them `resume`). The
|
||||
fallthrough is kept, so a press does not come back empty just because the tier it led with has
|
||||
nothing to offer."""
|
||||
skip = exclude or set()
|
||||
|
||||
def _resume() -> dict | None:
|
||||
for card in rails.continue_watching(db, user) if resume is None else resume:
|
||||
playable = rails.playable(db, user.id, card)
|
||||
# Excluded on the card the USER saw (the show), not only on what it resolves to, so a
|
||||
# rerolled-past show does not come straight back as its own next episode.
|
||||
if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip):
|
||||
continue
|
||||
if playable is not None and fits(playable["remaining_seconds"], budget):
|
||||
return playable
|
||||
return None
|
||||
|
||||
def _comfort() -> dict | None:
|
||||
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
|
||||
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
|
||||
return card
|
||||
return None
|
||||
|
||||
def _surprise() -> dict | None:
|
||||
# A wider draw than the one card we need: the shuffle is seeded, so without the headroom an
|
||||
# excluded pick would leave the tier empty rather than move past it.
|
||||
for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW):
|
||||
if cards.key(card) not in skip:
|
||||
return card
|
||||
return None
|
||||
|
||||
tiers = {"resume": _resume, "comfort": _comfort, "surprise": _surprise}
|
||||
offset = start % len(HERO_TIERS)
|
||||
for tier in HERO_TIERS[offset:] + HERO_TIERS[:offset]:
|
||||
card = tiers[tier]()
|
||||
if card is not None:
|
||||
return {"tier": tier, "card": card}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,173 @@
|
||||
"""The comfort shelf: pin a thing you can always fall back to, and have it be one click from playing.
|
||||
|
||||
Five kinds are pinnable — a video, a YouTube channel, a Plex show, a Plex movie, a playlist — because
|
||||
comfort media is almost always "put on X", a series or a channel, and only sometimes one particular
|
||||
video. `models.ComfortPin` carries that union as five nullable FKs under a `num_nonnulls(...) = 1`
|
||||
check; this module is where the union is spoken about in one vocabulary, so no call site invents a
|
||||
second one.
|
||||
|
||||
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
|
||||
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
|
||||
unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is
|
||||
short and hand-curated, and it buys the property the whole surface exists for: one click, no
|
||||
intermediate page, no second decision.
|
||||
|
||||
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
|
||||
yours" and the route turns that into its 404 — core never imports routes.
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.discover import cards, queries
|
||||
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import leaf_card, show_card
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||
PIN_COLUMNS: dict[str, str] = {
|
||||
"video": "video_id",
|
||||
"channel": "channel_id",
|
||||
"plex_item": "plex_item_id",
|
||||
"plex_show": "plex_show_id",
|
||||
"playlist": "playlist_id",
|
||||
}
|
||||
PIN_KINDS = tuple(PIN_COLUMNS)
|
||||
|
||||
|
||||
def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, object] | None:
|
||||
"""Translate the client's (kind, ref) into the (column, value) the row stores, or None.
|
||||
|
||||
The client speaks the ids the rest of the API speaks: a video id, a channel id, a Plex
|
||||
`rating_key`, a playlist id. Plex's internal integer keys never leave the backend, so the
|
||||
translation belongs here rather than in five call sites.
|
||||
|
||||
The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind
|
||||
(a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before the
|
||||
E24 review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at
|
||||
all — a constant that called itself the one vocabulary while nothing spoke it."""
|
||||
col = PIN_COLUMNS.get(kind)
|
||||
if col is None:
|
||||
return None
|
||||
if kind == "video":
|
||||
return (col, ref) if db.get(Video, ref) is not None else None
|
||||
if kind == "channel":
|
||||
return (col, ref) if db.get(Channel, ref) is not None else None
|
||||
if kind == "plex_item":
|
||||
it = by_rating_key(db, PlexItem, ref)
|
||||
return (col, it.id) if it is not None else None
|
||||
if kind == "plex_show":
|
||||
sh = by_rating_key(db, PlexShow, ref)
|
||||
return (col, sh.id) if sh is not None else None
|
||||
try:
|
||||
pid = int(ref)
|
||||
except ValueError:
|
||||
return None
|
||||
pl = db.get(Playlist, pid)
|
||||
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
|
||||
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
|
||||
# the same reason `owned_or_404` 404s rather than 403s.
|
||||
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
|
||||
|
||||
|
||||
def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(ComfortPin)
|
||||
.where(ComfortPin.user_id == user.id)
|
||||
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def next_position(db: Session, user: User) -> int:
|
||||
"""New pins land at the end of the shelf.
|
||||
|
||||
`max(position) + 1`, not the row count. Positions are never renumbered on removal — `remove_pin`
|
||||
deletes the row, and every FK cascade silently removes a pin when the thing it points at goes
|
||||
away — so past the first hole a count under-counts and would drop the new pin into the MIDDLE of
|
||||
the shelf, or onto a position another pin already holds (nothing in the schema forbids that).
|
||||
Same idiom as `routes/playlists._next_playlist_position`."""
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.coalesce(func.max(ComfortPin.position), -1)).where(
|
||||
ComfortPin.user_id == user.id
|
||||
)
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
|
||||
|
||||
def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
||||
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
|
||||
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
|
||||
card: dict | None = None
|
||||
nxt: dict | None = None
|
||||
|
||||
if pin.video_id is not None:
|
||||
row = queries.yt_video_row(db, user, pin.video_id)
|
||||
if row is None:
|
||||
return None
|
||||
card = cards.from_video(row)
|
||||
nxt = dict(card) # a video plays itself
|
||||
|
||||
elif pin.channel_id is not None:
|
||||
ch = db.get(Channel, pin.channel_id)
|
||||
if ch is None:
|
||||
return None
|
||||
card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id))
|
||||
row = queries.channel_next_video(db, user, ch.id)
|
||||
nxt = cards.from_video(row) if row else None
|
||||
|
||||
elif pin.plex_item_id is not None:
|
||||
it = db.get(PlexItem, pin.plex_item_id)
|
||||
if it is None:
|
||||
return None
|
||||
st = rollup.item_state_map(db, user.id, [it.id]).get(it.id)
|
||||
card = cards.from_plex(leaf_card(it, st))
|
||||
nxt = dict(card) # a movie or a single episode plays itself
|
||||
|
||||
elif pin.plex_show_id is not None:
|
||||
sh = db.get(PlexShow, pin.plex_show_id)
|
||||
if sh is None:
|
||||
return None
|
||||
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
||||
card = cards.from_plex(show_card(sh, status))
|
||||
ep = queries.next_episode(db, user.id, sh.id)
|
||||
nxt = cards.from_plex(ep) if ep else None
|
||||
|
||||
elif pin.playlist_id is not None:
|
||||
pl = db.get(Playlist, pin.playlist_id)
|
||||
if pl is None:
|
||||
return None
|
||||
count, row = queries.playlist_summary(db, user, pl.id)
|
||||
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
|
||||
nxt = cards.from_video(row) if row else None
|
||||
|
||||
if card is None:
|
||||
return None
|
||||
# A COPY, never the card itself: a self-playing pin (a video, a movie) would otherwise make
|
||||
# `card["next"] is card`, which serializes as a circular reference and 500s the whole shelf. The
|
||||
# copy is taken before `pin_id`/`next` are attached, so `next` stays a plain playable card.
|
||||
card["pin_id"] = pin.id
|
||||
card["next"] = nxt
|
||||
return card
|
||||
|
||||
|
||||
def shelf(db: Session, user: User) -> list[dict]:
|
||||
"""The whole shelf, in the user's own order."""
|
||||
out = []
|
||||
for pin in list_pins(db, user):
|
||||
card = _shelf_card(db, user, pin)
|
||||
if card is not None:
|
||||
out.append(card)
|
||||
return out
|
||||
|
||||
|
||||
def playables(shelf_cards: list[dict]) -> list[dict]:
|
||||
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
|
||||
with nothing unwatched left, or an empty playlist, simply is not an answer right now."""
|
||||
return [c["next"] for c in shelf_cards if c.get("next")]
|
||||
@@ -0,0 +1,348 @@
|
||||
"""The row fetchers behind the Home surface — one place where "what is startable right now" is
|
||||
expressed as SQL, for both sources.
|
||||
|
||||
Two rules shape this module:
|
||||
|
||||
**Reuse the query layer, not the route.** The YouTube half calls `feedquery.filtered_query` /
|
||||
`sort_keys`, so the Home surface inherits the feed's own definitions of subscribed, blocked,
|
||||
explore-visible and in-progress for free, and cannot drift from them. The Plex half calls
|
||||
`plex.rollup` and the `plex.cards` serializers rather than `routes/plex/discover.py`: that route
|
||||
narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a
|
||||
sidebar and wrong for a landing page that does not.
|
||||
|
||||
**One seed drives both halves.** The feed orders a shuffle by `md5(id || seed)` so a keyset cursor
|
||||
stays valid across pages; the Plex surprise rail uses `ORDER BY random()`, which is fine for a single
|
||||
un-paged draw but cannot be reproduced. A cross-source reroll needs both halves to move together —
|
||||
otherwise "Mást kérek" re-draws the Plex picks while the YouTube ones stand still — so the Plex side
|
||||
here takes the feed's seeded-md5 idiom instead of `random()`.
|
||||
"""
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.discover.budget import band
|
||||
from app.feedquery import (
|
||||
feed_columns,
|
||||
filtered_query,
|
||||
not_blocked_cond,
|
||||
order_by,
|
||||
serialize,
|
||||
sort_keys,
|
||||
)
|
||||
from app.models import (
|
||||
Channel,
|
||||
PlaylistItem,
|
||||
PlexItem,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import leaf_card, movie_card, show_card
|
||||
from app.plex.query import apply_show_state_filter, content_libs
|
||||
|
||||
# How many rows a single rail may carry. Matches `routes/plex/discover.py`'s RAIL_LIMIT on purpose —
|
||||
# the two surfaces should feel like the same app, and a rail is a rail.
|
||||
RAIL_LIMIT = 20
|
||||
|
||||
# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's
|
||||
# `_feed_params` defaults, because those are an HTTP contract that may change for the feed's own
|
||||
# reasons; the landing page's scope should not move with them silently.
|
||||
_FEED_BASE: dict = {
|
||||
"tags": [],
|
||||
"tag_modes": {},
|
||||
"untagged": False,
|
||||
"channel_ids": [],
|
||||
"q": None,
|
||||
"min_duration": None,
|
||||
"max_duration": None,
|
||||
"max_age_days": None,
|
||||
"published_after": None,
|
||||
"published_before": None,
|
||||
"show_normal": True,
|
||||
"include_shorts": False,
|
||||
"include_live": False,
|
||||
"show": "unwatched",
|
||||
"scope": "my",
|
||||
"library_source": "organic",
|
||||
}
|
||||
|
||||
|
||||
def _seeded(col, seed: int):
|
||||
"""The feed's deterministic shuffle expression, reused for Plex so one seed moves both halves."""
|
||||
return func.md5(func.concat(col, str(seed)))
|
||||
|
||||
|
||||
# ── YouTube ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def yt_in_progress(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]:
|
||||
"""Started-but-not-finished videos, most recently progressed first, as (progressed_at, row).
|
||||
|
||||
The predicate is `feedquery`'s `show="in_progress"` verbatim (a stored resume position, status not
|
||||
watched/hidden) — but the query is built here rather than through `filtered_query` because this
|
||||
rail wants an ordering by `progress_updated_at`, and that column lives on the state alias the feed
|
||||
query keeps private. Driving from `video_states` is also the faster plan: it is a per-user table of
|
||||
a few hundred rows, against a catalog of many thousands.
|
||||
|
||||
Building it here means the feed's OTHER predicates have to be chosen deliberately rather than
|
||||
inherited. `not_blocked_cond` is shared, because "never show me this channel" outranks "you
|
||||
started it" — blocking leaves the state row untouched, so without this the video the user just
|
||||
banned heads the resume rail. The feed's SCOPE clauses (subscribed / via_explore accessibility)
|
||||
are deliberately NOT applied, for the reason `yt_video_row` gives: something you have already
|
||||
started must stay resumable even after you unsubscribe or an exploration expires."""
|
||||
st = aliased(VideoState)
|
||||
q = (
|
||||
select(*feed_columns(user, st), st.progress_updated_at.label("progressed_at"))
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.join(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||
.where(
|
||||
st.position_seconds > 0,
|
||||
st.status.notin_(("watched", "hidden")),
|
||||
Video.unavailable_since.is_(None),
|
||||
not_blocked_cond(user),
|
||||
)
|
||||
.order_by(st.progress_updated_at.desc().nullslast())
|
||||
.limit(limit)
|
||||
)
|
||||
return [(r.progressed_at, serialize(r)) for r in db.execute(q).all()]
|
||||
|
||||
|
||||
def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) -> list[dict]:
|
||||
"""Unwatched videos from the user's own feed scope, in seeded-shuffle order, inside the budget.
|
||||
|
||||
Shorts are opened up only for the shortest band: "I have ten minutes" is the one answer a Short is
|
||||
actually a good response to, and the feed's own default keeps them out otherwise."""
|
||||
lo, hi = band(budget)
|
||||
params = dict(_FEED_BASE, min_duration=lo, max_duration=hi, include_shorts=budget == "short")
|
||||
query, rank_expr = filtered_query(db, user, **params)
|
||||
keys = sort_keys("shuffle", seed, rank_expr)
|
||||
# Over-fetch, then drop anything already started. The feed's `unwatched` scope keeps watched and
|
||||
# hidden out but deliberately keeps in-progress rows in (they belong in the feed). Here they must
|
||||
# go: the resume tier has already had its chance at them, and it may have passed precisely because
|
||||
# the REMAINING time did not fit the budget — offering the same item back as a surprise, whose
|
||||
# runtime does fit, would label a half-finished thing "Meglepetés" and contradict the question.
|
||||
rows = db.execute(query.order_by(*order_by(keys)).limit(limit * 4)).all()
|
||||
out = [serialize(r) for r in rows]
|
||||
return [r for r in out if not r["position_seconds"]][:limit]
|
||||
|
||||
|
||||
def channel_next_video(db: Session, user: User, channel_id: str) -> dict | None:
|
||||
"""The newest unwatched video of a pinned channel — what "put on that channel" resolves to."""
|
||||
# `library_source="all"`: a pinned channel means the whole channel, including videos that only
|
||||
# ever arrived through a search. The feed's "organic" default is a feed-scoping choice, not a
|
||||
# statement about what belongs to a channel.
|
||||
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
|
||||
query, rank_expr = filtered_query(db, user, **params)
|
||||
keys = sort_keys("newest", 0, rank_expr)
|
||||
row = db.execute(query.order_by(*order_by(keys)).limit(1)).first()
|
||||
return serialize(row) if row is not None else None
|
||||
|
||||
|
||||
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
||||
"""One video as a feed row, with this user's watch state.
|
||||
|
||||
Built directly rather than through `filtered_query` on purpose: a PINNED video must keep working
|
||||
even when it falls outside the feed's scope — the user unsubscribed from the channel, or blocked
|
||||
it, or the video only ever arrived through a search. The shelf is the surface you fall back to;
|
||||
it silently losing an entry because a filter moved is exactly the failure it must not have."""
|
||||
st = aliased(VideoState)
|
||||
row = db.execute(
|
||||
select(*feed_columns(user, st))
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||
.where(Video.id == video_id)
|
||||
).first()
|
||||
return serialize(row) if row is not None else None
|
||||
|
||||
|
||||
def channel_unwatched_count(db: Session, user: User, channel_id: str) -> int:
|
||||
"""How many unwatched videos a pinned channel is holding — the tile's only honest promise, since a
|
||||
channel has no runtime of its own."""
|
||||
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
|
||||
query, _ = filtered_query(db, user, **params)
|
||||
return int(db.execute(select(func.count()).select_from(query.subquery())).scalar() or 0)
|
||||
|
||||
|
||||
def playlist_summary(db: Session, user: User, playlist_id: int) -> tuple[int, dict | None]:
|
||||
"""(item count, the row to play next) for a pinned playlist — its first not-yet-watched item, or
|
||||
its first item once the whole list is watched. The same row doubles as the tile's artwork, so a
|
||||
shelf of playlists is scannable instead of a row of blank boxes."""
|
||||
st = aliased(VideoState)
|
||||
rows = db.execute(
|
||||
select(*feed_columns(user, st), PlaylistItem.position)
|
||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||
.where(PlaylistItem.playlist_id == playlist_id)
|
||||
.order_by(PlaylistItem.position.asc())
|
||||
).all()
|
||||
if not rows:
|
||||
return 0, None
|
||||
nxt = next((r for r in rows if (r.status or "new") not in ("watched", "hidden")), rows[0])
|
||||
return len(rows), serialize(nxt)
|
||||
|
||||
|
||||
# ── Plex ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _unseen_movie_conds(uid: int):
|
||||
"""A movie the user has neither finished nor hidden. A LEFT-JOIN-IS-NULL would also drop rows that
|
||||
merely carry a resume position, which are exactly the ones Continue watching wants back."""
|
||||
seen = (
|
||||
select(1)
|
||||
.select_from(PlexState)
|
||||
.where(
|
||||
PlexState.item_id == PlexItem.id,
|
||||
PlexState.user_id == uid,
|
||||
or_(
|
||||
PlexState.status.in_(("watched", "hidden")),
|
||||
# Started counts as "not a surprise" too — see the note in `yt_surprise`.
|
||||
PlexState.position_seconds > 0,
|
||||
),
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
return ~seen
|
||||
|
||||
|
||||
def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]:
|
||||
"""In-progress movies + partially-watched shows as (progressed_at, card).
|
||||
|
||||
Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
|
||||
NULL status still counts as started, so `notin_` alone would drop it), and the same shared
|
||||
`rollup.agg_subquery` + `apply_show_state_filter` for shows, so the two rails cannot disagree on
|
||||
what "in progress" means. What it does NOT inherit is that route's `LibraryFilters` narrowing:
|
||||
this surface has no Plex sidebar to read a filter from."""
|
||||
movie_libs, show_libs = content_libs(db)
|
||||
out: list[tuple[object, dict]] = []
|
||||
|
||||
if movie_libs:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_libs),
|
||||
PlexState.position_seconds > 0,
|
||||
or_(
|
||||
PlexState.status.is_(None),
|
||||
PlexState.status.notin_(("watched", "hidden")),
|
||||
),
|
||||
)
|
||||
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||
.limit(limit)
|
||||
)
|
||||
for it, st in db.execute(mq).all():
|
||||
out.append((st.progress_updated_at, movie_card(it, st)))
|
||||
|
||||
if show_libs:
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).where(PlexShow.library_id.in_(show_libs))
|
||||
for sh, resumed_at in db.execute(
|
||||
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
||||
).all():
|
||||
out.append((resumed_at, show_card(sh, "in_progress")))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) -> list[dict]:
|
||||
"""Unseen playable movies inside the budget, in seeded-shuffle order.
|
||||
|
||||
Movies only. A show is not a thing you can start playing, and resolving every candidate show to
|
||||
its next episode just to draw three cards would be a query per row for a rail whose whole value is
|
||||
that it answers instantly — shows reach this surface through Continue watching and the comfort
|
||||
shelf, which is where "put the next episode on" actually belongs.
|
||||
|
||||
No `playable` filter, matching the Plex surprise rail: `PlexItem.playable` is a playback CLASS
|
||||
("direct" / "remux" / "transcode"), not a yes/no, and NULL there means "not probed yet" rather than
|
||||
"will not play". Excluding un-probed items would quietly hide a fresh library from the one surface
|
||||
that is supposed to have an answer. The class travels on the card, so the client still shows it."""
|
||||
movie_libs, _ = content_libs(db)
|
||||
if not movie_libs:
|
||||
return []
|
||||
lo, hi = band(budget)
|
||||
q = (
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_libs),
|
||||
_unseen_movie_conds(uid),
|
||||
)
|
||||
.order_by(_seeded(PlexItem.rating_key, seed))
|
||||
.limit(limit)
|
||||
)
|
||||
if lo is not None:
|
||||
q = q.where(PlexItem.duration_s >= lo)
|
||||
if hi is not None:
|
||||
q = q.where(PlexItem.duration_s <= hi)
|
||||
items = db.execute(q).scalars().all()
|
||||
states = rollup.item_state_map(db, uid, [it.id for it in items])
|
||||
return [movie_card(it, states.get(it.id)) for it in items]
|
||||
|
||||
|
||||
# Watch states that take an episode out of the running for "what would play next".
|
||||
_DONE = ("watched", "hidden")
|
||||
|
||||
|
||||
def next_episode(db: Session, uid: int, show_id: int) -> dict | None:
|
||||
"""What "put that show on" means: the episode in progress, else the first unwatched one, else the
|
||||
first — over the same `(season, episode)` broadcast ordering `detail.py::_episode_nav` uses.
|
||||
|
||||
Three ordered `LIMIT 1` probes rather than one scan picked apart in Python. The Python version
|
||||
loaded EVERY episode of the show to choose one of them — 236 rows for Friends, 227 for JAG — which
|
||||
the comfort shelf could just about afford (a handful of hand-picked pins) and the resume rail
|
||||
could not, since it resolves a show per rail row on the landing page.
|
||||
|
||||
Returned as a `leaf_card` so it carries the show title the episode row itself does not."""
|
||||
asc = (
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
base = (
|
||||
select(PlexItem, PlexState)
|
||||
.outerjoin(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
|
||||
.limit(1)
|
||||
)
|
||||
# In progress — the LAST one in broadcast order, which is `plex.rollup.rollup`'s `inprog[-1]`,
|
||||
# i.e. what the show page's own Resume button plays. Two episodes of one show are in progress at
|
||||
# once more often than it sounds (`watch_sync._reconcile_inprogress_movies` documents that a
|
||||
# scrub to the end leaves a stale `status='new', position>0` row until the daily reconcile), and
|
||||
# taking opposite ends of that set is how one show came to have two different "continue" answers:
|
||||
# the Home tile ranked it by its LATEST touch and then offered the EARLIEST started episode.
|
||||
# `position_seconds > 0` already requires the state row to exist, so no null guard.
|
||||
pick = db.execute(
|
||||
base.where(PlexState.position_seconds > 0, PlexState.status.notin_(_DONE)).order_by(
|
||||
PlexItem.season_number.desc().nullslast(),
|
||||
PlexItem.episode_number.desc().nullslast(),
|
||||
)
|
||||
).first()
|
||||
# Unwatched — INCLUDING an episode with no state row at all, which is the common case and the one
|
||||
# a bare `status NOT IN (...)` silently drops (NULL is not false, it is unknown, so the row fails
|
||||
# the filter).
|
||||
pick = (
|
||||
pick
|
||||
or db.execute(
|
||||
base.where(or_(PlexState.id.is_(None), PlexState.status.notin_(_DONE))).order_by(*asc)
|
||||
).first()
|
||||
)
|
||||
# Fully watched show: start it again from the top.
|
||||
pick = pick or db.execute(base.order_by(*asc)).first()
|
||||
if pick is None:
|
||||
return None
|
||||
|
||||
ep, st = pick
|
||||
show = db.get(PlexShow, show_id)
|
||||
return leaf_card(
|
||||
ep, st, show_title=show.title if show else None, show_rk=show.rating_key if show else None
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""The Home surface's rails: cross-source lists, assembled from `queries.py` and emitted as unified
|
||||
cards.
|
||||
|
||||
The whole point of this module is that a rail is ONE list, not a YouTube list beside a Plex list. Two
|
||||
side-by-side rails would just move the choice the user could not make ("which half do I look at?") one
|
||||
level up, which is the opposite of what this epic is for.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.discover import cards, queries
|
||||
from app.discover.queries import RAIL_LIMIT
|
||||
from app.models import PlexShow, User
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.rollup import NO_TS
|
||||
|
||||
|
||||
def plex_available(db: Session) -> bool:
|
||||
"""Plex is a deployment-level toggle, not a per-user one (see `routes/me.py`). With it off, the
|
||||
Home rails are YouTube-only and say nothing about Plex."""
|
||||
return sysconfig.get_bool(db, "plex_enabled")
|
||||
|
||||
|
||||
def playable(db: Session, uid: int, card: dict) -> dict | None:
|
||||
"""The thing that would actually START from this card — itself for anything with a runtime, and
|
||||
for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first).
|
||||
|
||||
ONE function, because three surfaces need the same answer and a second implementation is how the
|
||||
shelf and the rail came to disagree in the first place: the hero calls it to decide what it is
|
||||
offering, `continue_watching` calls it to fill each show tile's `next`, and the comfort shelf
|
||||
resolves the same way in `pins`. None when the show has no episodes mirrored yet.
|
||||
|
||||
A card that already carries a resolved `next` (a rail row) is taken at its word rather than
|
||||
re-queried — the hero reads the rail it was handed."""
|
||||
if not (card.get("source") == "plex" and card.get("kind") == "show"):
|
||||
return card
|
||||
if "next" in card:
|
||||
return card["next"]
|
||||
sh = by_rating_key(db, PlexShow, card["id"])
|
||||
if sh is None:
|
||||
return None
|
||||
ep = queries.next_episode(db, uid, sh.id)
|
||||
return cards.from_plex(ep) if ep else None
|
||||
|
||||
|
||||
def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[dict]:
|
||||
"""One resume rail across both sources, most recently progressed first.
|
||||
|
||||
A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the
|
||||
user recognises — but it carries `next`: the episode that would actually start. Without it a
|
||||
click on a half-watched series landed on the show page with six seasons to choose between, which
|
||||
is the decision this whole surface exists to remove (the comfort shelf and the hero already
|
||||
resolved; only this rail did not). The client plays `next ?? card`, so the tile stays a poster and
|
||||
the click stays one click."""
|
||||
rows: list[tuple[object, dict]] = [
|
||||
(ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit)
|
||||
]
|
||||
if plex_available(db):
|
||||
rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)]
|
||||
rows.sort(key=lambda r: r[0] or NO_TS, reverse=True)
|
||||
out = [card for _, card in rows[:limit]]
|
||||
# Resolve AFTER the slice: only the rows that survive it are worth a query.
|
||||
for card in out:
|
||||
if card["kind"] == "show":
|
||||
card["next"] = playable(db, user.id, card)
|
||||
return out
|
||||
|
||||
|
||||
def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> list[dict]:
|
||||
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
|
||||
|
||||
Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its
|
||||
movie/show split — so a short pool never comes back all-YouTube or all-Plex just because one side
|
||||
happened to be queried first.
|
||||
|
||||
WHICH source leads is decided by the seed, not by a hardcoded order. The hero draws a pool of ONE,
|
||||
so a fixed order would mean the Plex library could never answer "egy este" while a single YouTube
|
||||
video also fit — the reroll would look broken and the cross-source promise would quietly be a
|
||||
YouTube promise. Parity on the same seed that orders the rows keeps it deterministic: the same
|
||||
seed always gives the same answer, and pressing "Mást kérek" moves both halves together."""
|
||||
halves = [
|
||||
(cards.from_video, queries.yt_surprise(db, user, budget, seed, limit)),
|
||||
(
|
||||
cards.from_plex,
|
||||
queries.plex_surprise(db, user.id, budget, seed, limit) if plex_available(db) else [],
|
||||
),
|
||||
]
|
||||
if seed % 2:
|
||||
halves.reverse()
|
||||
|
||||
out: list[dict] = []
|
||||
for i in range(max(len(rows) for _, rows in halves)):
|
||||
for to_card, rows in halves:
|
||||
if i < len(rows):
|
||||
out.append(to_card(rows[i]))
|
||||
return out[:limit]
|
||||
@@ -54,6 +54,10 @@ class EditAborted(Exception):
|
||||
|
||||
# --- edit spec ------------------------------------------------------------------------------
|
||||
|
||||
# SEC-13: ceiling on a join's cut-list. An accurate join emits two filters per segment into a single
|
||||
# ffmpeg `filter_complex`, so "however many the caller sent" is an unbounded command line.
|
||||
_MAX_SEGMENTS = 100
|
||||
|
||||
|
||||
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
||||
@@ -88,6 +92,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
if en > st:
|
||||
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
|
||||
norm.sort(key=lambda x: x["start_s"])
|
||||
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
|
||||
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
|
||||
# The cap is far above any real cut-list (the UI's own splitter produces a handful) and the
|
||||
# excess is DROPPED rather than raising: the surplus segments are the attack, the first
|
||||
# _MAX_SEGMENTS are a legitimate edit.
|
||||
del norm[_MAX_SEGMENTS:]
|
||||
if norm:
|
||||
out["segments"] = norm
|
||||
# A join always chooses a codec path: accurate=filter-concat re-encode,
|
||||
|
||||
@@ -8,9 +8,28 @@ explanation (and decides whether a retry makes sense). The raw text stays availa
|
||||
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
||||
"""
|
||||
|
||||
# Two worker-authored strings that this module owns because both are coupled to `_RULES`.
|
||||
#
|
||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
||||
#
|
||||
# NETWORK_REDACTED_MESSAGE is the opposite direction: it REPLACES a message after classification, so
|
||||
# it deliberately has no rule. Nothing re-classifies a stored `job.error` today; if anything ever
|
||||
# does, it must carry the original code rather than re-deriving one from this text.
|
||||
#
|
||||
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
|
||||
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
||||
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||
|
||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
||||
# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message.
|
||||
_RULES: tuple[tuple[str, str], ...] = (
|
||||
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||
("download storage is full", "quota_exceeded"),
|
||||
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
|
||||
("sign in to confirm your age", "login_required"),
|
||||
("confirm your age", "login_required"),
|
||||
@@ -91,3 +110,20 @@ def classify(msg: str | None) -> str:
|
||||
if marker in low:
|
||||
return category
|
||||
return "unknown"
|
||||
|
||||
|
||||
def user_error_text(source_kind: str, msg: str, code: str) -> str:
|
||||
"""The failure text to STORE on the job, given the raw message and its category.
|
||||
|
||||
SEC-12: for a user-supplied URL, a transport-level failure text distinguishes "connection
|
||||
refused" from "HTTP Error 401" from "timed out" per host:port — which is a port scanner with
|
||||
extra steps, since the raw text is shown to the user behind the details disclosure. `urlguard`
|
||||
stops the fetch aiming at our own network; this stops the remaining readout.
|
||||
|
||||
Only the network class, and only for ``url`` jobs: a YouTube job has no user-chosen host, and
|
||||
every other class (login_required, geo_blocked, drm, …) is real, useful diagnosis. A separate
|
||||
function rather than an inline branch in the worker so it can be pinned by a test — the raw
|
||||
text leaking back is a security regression that would otherwise pass a green suite."""
|
||||
if source_kind == "url" and code == "network":
|
||||
return NETWORK_REDACTED_MESSAGE
|
||||
return msg
|
||||
|
||||
@@ -116,6 +116,7 @@ def build_ydl_opts(
|
||||
progress_hook,
|
||||
extra: dict | None = None,
|
||||
cookiefile: str | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> dict:
|
||||
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
||||
|
||||
@@ -125,12 +126,20 @@ def build_ydl_opts(
|
||||
|
||||
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
|
||||
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||
merge so a source adapter can never clobber it."""
|
||||
merge so a source adapter can never clobber it.
|
||||
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
||||
s = normalize(spec)
|
||||
h = s["max_height"]
|
||||
opts: dict = {
|
||||
"outtmpl": outtmpl,
|
||||
"noplaylist": True,
|
||||
# SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to the single video. A
|
||||
# URL that is PURELY a playlist or channel has no such video to fall back to, so it still
|
||||
# expands into every entry — one enqueue, an unbounded number of downloads. One job means
|
||||
# one item; a user who wants the whole list enqueues it item by item, under their quota.
|
||||
"playlist_items": "1",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": True,
|
||||
@@ -154,6 +163,16 @@ def build_ydl_opts(
|
||||
if extra:
|
||||
opts.update(extra)
|
||||
|
||||
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
|
||||
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
|
||||
#
|
||||
# A non-positive budget is left unset because 0 is not an expressible yt-dlp filter, NOT because
|
||||
# nothing needs to happen: the worker refuses that job outright before extraction (`_OwnerRefused`
|
||||
# → `quota_exceeded`). Enqueue cannot make that call — it ran while the user was still under the
|
||||
# cap. Delete the worker's refusal and this branch silently uncaps the one user already over it.
|
||||
if max_bytes and max_bytes > 0:
|
||||
opts["max_filesize"] = max_bytes
|
||||
|
||||
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
|
||||
# be able to override the authenticated session the worker resolved for this specific user.
|
||||
if cookiefile:
|
||||
|
||||
@@ -15,7 +15,8 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
@@ -36,6 +37,18 @@ class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
class _OwnerRefused(Exception):
|
||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||
|
||||
MediaAssets are shared: two users asking for the same video and format get one row (see
|
||||
`service.get_or_create_asset`), and the generic failure handler writes the message onto it —
|
||||
correctly, because an upstream failure really is everyone's. A per-user refusal is not. Written
|
||||
to the shared row it would fail a second user's queued job for the same video with the first
|
||||
user's private reason (`worker._process_job` reads `asset.error` verbatim), telling someone with
|
||||
an empty library to free up space. So this is caught separately: the job fails, the asset goes
|
||||
back to `pending` for whoever else is holding it."""
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
@@ -305,13 +318,17 @@ def _media_info(info: dict) -> dict:
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None):
|
||||
def _extract_with_fallback(
|
||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
|
||||
):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt.
|
||||
|
||||
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated."""
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
||||
|
||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
@@ -322,6 +339,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookie
|
||||
_make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
cookiefile=cookiefile,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
@@ -415,8 +433,17 @@ def _download(
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
# SEC-12, second half: the enqueue guard ran when the job was created, which may have been
|
||||
# hours ago and against a different DNS answer. This is where the socket actually gets
|
||||
# opened, so the check is repeated here and the job fails rather than fetching.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(url)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e
|
||||
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||
remaining_bytes: int | None = None
|
||||
with SessionLocal() as cdb:
|
||||
asset_sig = cdb.execute(
|
||||
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
||||
@@ -425,6 +452,19 @@ def _download(
|
||||
cookiefile = credentials.resolve_cookiefile(
|
||||
cdb, user_id, url, asset_sig, spec, staging
|
||||
)
|
||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||
# single job could previously blow through the cap by any amount. Handing the remainder
|
||||
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
|
||||
# front instead.
|
||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
||||
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||
# passing it down would leave the ceiling unset — the one user already at their cap would be
|
||||
# the only one downloading unbounded. Fail the job here instead. Not retryable until they
|
||||
# free space, which is what the `quota_exceeded` category tells them.
|
||||
if remaining_bytes == 0:
|
||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
info = _media_info(
|
||||
_extract_with_fallback(
|
||||
job_id,
|
||||
@@ -434,6 +474,7 @@ def _download(
|
||||
staging,
|
||||
adapter,
|
||||
cookiefile=str(cookiefile) if cookiefile else None,
|
||||
max_bytes=remaining_bytes,
|
||||
)
|
||||
)
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
@@ -525,8 +566,31 @@ def _download(
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except _OwnerRefused as exc:
|
||||
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
|
||||
# so another user's job for the same video is claimed and downloaded normally instead of
|
||||
# inheriting a reason that is about someone else's account.
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
service.finalize_if_running(
|
||||
db,
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
# Released AFTER the job is terminal: the moment the asset is `pending` another job can claim
|
||||
# it, and this one must already be out of the way. (The staging tree is still torn down in
|
||||
# `finally` below — the same narrow window the pause/cancel path above has always had.)
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s refused for its owner: %s", job_id, msg)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
code = dlerrors.classify(msg)
|
||||
# SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text).
|
||||
msg = dlerrors.user_error_text(source_kind, msg, code)
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
@@ -539,7 +603,7 @@ def _download(
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
error_code=code,
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -7,6 +7,13 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys
|
||||
|
||||
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
|
||||
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
||||
|
||||
DOWNLOAD jobs additionally consult `remaining_bytes` in the worker, because enqueue can only compare
|
||||
the footprint as it stood when the job was created and earlier jobs may have filled the cap since.
|
||||
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -122,6 +129,21 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||
|
||||
|
||||
def remaining_bytes(db: Session, user_id: int) -> int | None:
|
||||
"""Storage this user may still spend on ONE download: ``None`` for an unlimited user, else the
|
||||
cap minus the current footprint, floored at 0.
|
||||
|
||||
Zero is a real answer, not "no limit" — it means the cap is already met and the caller must
|
||||
REFUSE rather than proceed unbounded. `check_enqueue` cannot make that refusal on the caller's
|
||||
behalf: it ran when the job was queued, while the user was still under the cap, and the jobs
|
||||
that filled it have landed since. Keeping the arithmetic here (rather than inline in the worker)
|
||||
is what keeps every max_bytes decision in this module."""
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return None
|
||||
return max(0, lim.max_bytes - footprint(db, user_id))
|
||||
|
||||
|
||||
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Refuse a user-supplied download URL that points back into our own network (SEC-12).
|
||||
|
||||
The Download Center deliberately accepts an arbitrary ``http(s)`` URL — that is the feature — and
|
||||
hands it to yt-dlp running on the WORKER. Without a guard, any approved user can make the server
|
||||
fetch anything the server can reach: other containers on the compose network, the LAN, a cloud
|
||||
metadata endpoint. The failure text then comes back to them in ``job.error``, so "connection
|
||||
refused" vs "HTTP Error 401" vs "timed out" turns the download box into an internal port scanner.
|
||||
|
||||
What this closes: a URL whose host resolves to a loopback / private / link-local / reserved address
|
||||
is refused, at enqueue AND again in the worker before extraction. Both, because DNS can answer
|
||||
differently between the two moments, and the worker is where the socket is actually opened.
|
||||
|
||||
What it does NOT close, stated plainly rather than implied, because both need the same fix — gating
|
||||
the connection itself with a custom socket factory inside yt-dlp, a much larger change than this:
|
||||
|
||||
* DNS rebinding: a host that passes the worker's check and then answers yt-dlp's own resolution
|
||||
with an internal address.
|
||||
* HTTP redirects: a public host that answers 3xx with an internal ``Location``. yt-dlp follows it,
|
||||
and only the URL the user TYPED was ever judged here. An attacker-controlled host or any public
|
||||
open redirect reaches it, so this is the cheaper of the two. It does NOT usually yield the
|
||||
response body — a non-media body fails the mp4 remux and stores nothing — but it does hand back
|
||||
the readout above: the redaction in ``errors.user_error_text`` fires only for the ``network``
|
||||
class, so a dead internal port (refused → ``network`` → redacted) is distinguishable from a live
|
||||
one (an HTTP answer that is not media → ``unsupported_host``/``format_unavailable``, raw text
|
||||
kept). Tracked as SEC-17.
|
||||
|
||||
The two checks here stop the straightforward case (typing ``http://127.0.0.1:6379`` or an internal
|
||||
hostname into the box), which is the reachable one.
|
||||
|
||||
Not used for YouTube jobs: those resolve to a fixed public host from an 11-char id, so there is no
|
||||
user-controlled destination to guard.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
||||
# vocabulary (frontend `localizeDetail`) — keep them stable.
|
||||
REASON_SCHEME = "scheme" # not http(s)
|
||||
REASON_NO_HOST = "no_host" # URL carries no hostname
|
||||
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
|
||||
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
|
||||
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
|
||||
|
||||
|
||||
class BlockedUrl(Exception):
|
||||
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
||||
REASON_* codes above."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Every address family we must not open a connection to. Checked explicitly rather than
|
||||
leaning on ``is_private`` alone, whose coverage differs between IPv4 and IPv6."""
|
||||
# ::ffff:127.0.0.1 is loopback wearing an IPv6 hat — unwrap before judging it.
|
||||
mapped = getattr(ip, "ipv4_mapped", None)
|
||||
if mapped is not None:
|
||||
ip = mapped
|
||||
return bool(
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _resolve(host: str, port: int | None) -> list[str]:
|
||||
"""Every address ``host`` resolves to. Raises BlockedUrl(REASON_UNRESOLVABLE) when DNS has no
|
||||
answer — reported as its own outcome, because "we could not look that up" and "we refuse to
|
||||
fetch that" are different things to tell a user."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as exc:
|
||||
raise BlockedUrl(REASON_UNRESOLVABLE) from exc
|
||||
return [info[4][0] for info in infos]
|
||||
|
||||
|
||||
def check_url(raw: str) -> None:
|
||||
"""Raise BlockedUrl unless ``raw`` is an http(s) URL whose host resolves only to addresses the
|
||||
server is allowed to fetch from. A host with even ONE forbidden address is refused: a
|
||||
round-robin answer must not become a coin flip."""
|
||||
# `urlparse` itself raises ValueError on a malformed authority ("http://[::1/x" — an unclosed
|
||||
# bracket), and `.port` raises on ":99999" / ":abc" when READ rather than at parse time. Both
|
||||
# escape the caller's `except BlockedUrl` as a 500 unless caught here, which is the opposite of
|
||||
# what this module exists to do: turn a bad URL into a structured, localizable refusal.
|
||||
try:
|
||||
u = urlparse((raw or "").strip())
|
||||
port = u.port
|
||||
except ValueError as exc:
|
||||
raise BlockedUrl(REASON_MALFORMED) from exc
|
||||
if u.scheme not in ("http", "https"):
|
||||
raise BlockedUrl(REASON_SCHEME)
|
||||
host = u.hostname
|
||||
if not host:
|
||||
raise BlockedUrl(REASON_NO_HOST)
|
||||
for addr in _resolve(host, port):
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError: # getaddrinfo returned something unparseable; treat as unusable
|
||||
raise BlockedUrl(REASON_PRIVATE) from None
|
||||
if _is_forbidden(ip):
|
||||
raise BlockedUrl(REASON_PRIVATE)
|
||||
@@ -117,6 +117,19 @@ def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
|
||||
return out
|
||||
|
||||
|
||||
def not_blocked_cond(user: User):
|
||||
"""Per-user channel blocklist: never show this user videos from a channel they blocked
|
||||
(applies to feed, Library, explore, the channel page and the Home resume rail alike).
|
||||
|
||||
A shared expression rather than a clause each surface writes for itself. Blocking does not touch
|
||||
`video_states`, so a half-watched video from a newly blocked channel survives with its resume
|
||||
position — and the one query that copied only PART of this predicate set is exactly how that
|
||||
video came back as the landing page's suggestion after the user had said "never again"."""
|
||||
return Video.channel_id.notin_(
|
||||
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
def filtered_query(
|
||||
db: Session,
|
||||
user: User,
|
||||
@@ -207,13 +220,7 @@ def filtered_query(
|
||||
)
|
||||
)
|
||||
|
||||
# Per-user channel blocklist: never show this user videos from a channel they blocked
|
||||
# (applies to feed, Library, explore, and the channel page alike).
|
||||
query = query.where(
|
||||
Video.channel_id.notin_(
|
||||
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
|
||||
)
|
||||
)
|
||||
query = query.where(not_blocked_cond(user))
|
||||
|
||||
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
|
||||
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.routes import (
|
||||
auth as auth_routes,
|
||||
channels,
|
||||
config as config_routes,
|
||||
discover as discover_routes,
|
||||
downloads,
|
||||
feed,
|
||||
health,
|
||||
@@ -187,6 +188,7 @@ app.include_router(auth_routes.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(tags.router)
|
||||
app.include_router(feed.router)
|
||||
app.include_router(discover_routes.router)
|
||||
app.include_router(search_routes.router)
|
||||
app.include_router(me.router)
|
||||
app.include_router(notifications.router)
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import date, datetime
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Computed,
|
||||
Date,
|
||||
DateTime,
|
||||
@@ -1406,3 +1407,52 @@ class PlexPlaylistItem(Base):
|
||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
|
||||
|
||||
class ComfortPin(Base, TimestampMixin):
|
||||
"""One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X" (E24).
|
||||
|
||||
Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string
|
||||
pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is
|
||||
usually "put on X", a series or a channel, not one particular video. A string ref would order and
|
||||
hold anything, but it would also lose the foreign key: a Plex item dropped from the library, or an
|
||||
unsubscribed channel, would leave a dead tile on the one surface whose whole job is to be
|
||||
reliable. So instead: one nullable CASCADE-ing FK per pinnable kind, and a check constraint that
|
||||
exactly one of them is set.
|
||||
|
||||
That constraint is the house rule applied to a five-way union — the invariant lives in the schema,
|
||||
not in a comment every insert site has to remember. `kind`/`ref` below derive FROM the columns, so
|
||||
there is no second vocabulary to keep in sync.
|
||||
|
||||
NULLs are DISTINCT in a Postgres unique index, so the five per-kind unique constraints each stop a
|
||||
double-pin of the same thing without colliding across kinds."""
|
||||
|
||||
__tablename__ = "comfort_pins"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1",
|
||||
name="ck_comfort_pin_one_ref",
|
||||
),
|
||||
UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"),
|
||||
UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"),
|
||||
UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"),
|
||||
UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"),
|
||||
UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
video_id: Mapped[str | None] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"))
|
||||
channel_id: Mapped[str | None] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||||
plex_item_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_items.id", ondelete="CASCADE")
|
||||
)
|
||||
plex_show_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_shows.id", ondelete="CASCADE")
|
||||
)
|
||||
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"))
|
||||
|
||||
# 0-based shelf order. The shelf is short and hand-curated, so a dense integer the reorder
|
||||
# endpoint rewrites wholesale beats a fractional index nobody would ever exercise here.
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
|
||||
@@ -8,8 +8,9 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PlexItem
|
||||
from app.models import PlexItem, PlexLibrary
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
|
||||
@@ -342,3 +343,26 @@ def apply_show_state_filter(query, tot, wat, inp, show: str):
|
||||
if show == "unwatched":
|
||||
return query.where(wat == 0, inp == 0)
|
||||
return query
|
||||
|
||||
|
||||
def content_libs(db: Session) -> tuple[list[int], list[int]]:
|
||||
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
||||
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
||||
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).
|
||||
|
||||
Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's
|
||||
Home surface needs the same scope, and a domain package importing from `routes/` would invert the
|
||||
one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the
|
||||
move."""
|
||||
libs = (
|
||||
db.execute(
|
||||
select(PlexLibrary).where(
|
||||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
|
||||
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
|
||||
return movie_lib_ids, show_lib_ids
|
||||
|
||||
@@ -3,11 +3,25 @@
|
||||
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import PlexItem, PlexState
|
||||
|
||||
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
||||
# same at every rail that merges two type-homogeneous lists in Python.
|
||||
#
|
||||
# It is NOT a crash fix, whatever the E24 commit that introduced it says. The `(ts is not None, ts)`
|
||||
# key it replaced cannot raise: tuple comparison finds the first UNEQUAL element via `==`, so two
|
||||
# `(False, None)` keys compare equal and `None < None` is never reached — verified by execution
|
||||
# during the E24 epic-end review, over every permutation of null/non-null rows. What the sentinel
|
||||
# buys is one idiom instead of two, and a key that says "missing sorts last" outright rather than
|
||||
# encoding it in a boolean flag. A BARE `r[0]` would genuinely raise, which is the mutation the
|
||||
# rails' tests do guard against.
|
||||
NO_TS = datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def show_status(total: int, watched: int, inprog: int) -> str:
|
||||
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
|
||||
|
||||
@@ -32,7 +32,12 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.routes._common import get_or_404
|
||||
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link
|
||||
from app.routes.auth import (
|
||||
APPROVAL_VERIFY_TTL,
|
||||
issue_set_password_link,
|
||||
issue_verification_link,
|
||||
send_verification_link,
|
||||
)
|
||||
from app.utils import canonical_email, iso, valid_email
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
@@ -168,9 +173,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
|
||||
|
||||
|
||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
|
||||
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
|
||||
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||
@@ -361,9 +364,7 @@ def delete_invite(
|
||||
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
||||
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
if _accounts_for(db, [inv.email]):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -397,7 +398,15 @@ def _serialize_user(u: User) -> dict:
|
||||
"email_verified": u.email_verified,
|
||||
"is_demo": u.is_demo,
|
||||
"has_password": u.password_hash is not None,
|
||||
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
|
||||
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
|
||||
# deciding what kind of help someone needs.
|
||||
"password_revoked_at": iso(u.password_revoked_at),
|
||||
"has_google": u.google_sub is not None,
|
||||
# Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same
|
||||
# statement as "connected as someone else", and the second one is the one that explains a
|
||||
# confusing sign-in.
|
||||
"google_email": u.google_email if u.google_email != u.email else None,
|
||||
"created_at": iso(u.created_at),
|
||||
}
|
||||
|
||||
@@ -612,6 +621,63 @@ def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/access")
|
||||
def user_access(
|
||||
user_id: int,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the account panel shows about why this person can or cannot get in (E12 S5).
|
||||
|
||||
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
|
||||
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
|
||||
per row on every load to answer a question nobody asked yet."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
return _serialize_user(target) | {
|
||||
"is_allowed": is_allowed(db, target.email),
|
||||
"can_activate": can_activate(db, target),
|
||||
"email_configured": email_mod.email_enabled(db),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/set-password-link")
|
||||
def create_set_password_link(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a one-time link that lets this account set a password, with no email involved (E12 S5).
|
||||
|
||||
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
|
||||
hands the link over by whatever channel they trust; opening it sets a password and signs out
|
||||
whoever held the old one.
|
||||
|
||||
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
|
||||
revoked password, for a Google-only account that wants one, and for a plain forgotten password on
|
||||
an instance with no mail — narrowing it to one of those would only make the admin's real case the
|
||||
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
|
||||
change its password is handing them everyone else's session."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
|
||||
)
|
||||
url, expires_at = issue_set_password_link(db, target)
|
||||
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
|
||||
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
|
||||
# for whom, is the auditable fact.
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_SETPW_LINK,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/activate")
|
||||
def activate_user(
|
||||
user_id: int,
|
||||
|
||||
@@ -52,6 +52,16 @@ VERIFY_TTL = timedelta(hours=24)
|
||||
# in and sets no password.
|
||||
APPROVAL_VERIFY_TTL = timedelta(days=7)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the
|
||||
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
|
||||
# the affordance the admin was shown.
|
||||
ADMIN_SETPW_TTL = timedelta(hours=24)
|
||||
# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same
|
||||
# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a
|
||||
# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address;
|
||||
# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must
|
||||
# not silently satisfy the email gate. See password_reset_confirm.
|
||||
_PASSWORD_TOKEN_KINDS = ("reset", "setpw")
|
||||
|
||||
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
|
||||
@@ -773,6 +783,35 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
return db.get(User, row.user_id)
|
||||
|
||||
|
||||
def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None:
|
||||
"""Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not
|
||||
swallowed, because the caller has to treat them differently — only a mailed reset attests the
|
||||
address."""
|
||||
for kind in _PASSWORD_TOKEN_KINDS:
|
||||
user = _consume_token(db, raw, kind)
|
||||
if user is not None:
|
||||
return user, kind
|
||||
return None
|
||||
|
||||
|
||||
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
|
||||
"""An admin-issued, single-use link that lets `user` set a password without any email (E12 S5).
|
||||
|
||||
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
|
||||
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
|
||||
stays takeable until its owner resets, and password-reset needs working mail), and the everyday
|
||||
"no SMTP configured and someone forgot their password".
|
||||
|
||||
Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the
|
||||
account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch,
|
||||
which is what evicts a squatter — see password_reset_confirm."""
|
||||
expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL
|
||||
raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL)
|
||||
# The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to
|
||||
# consume it, and a second entry shape would be a second thing to keep correct.
|
||||
return f"{_app_base()}/#reset={raw}", expires_at
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
body: EmailPasswordIn,
|
||||
@@ -1105,7 +1144,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(
|
||||
"""
|
||||
if not _reset_check_limiter.allow(_client_ip(request)):
|
||||
return {"ok": True}
|
||||
return {"ok": _peek_token(db, body.token, "reset") is not None}
|
||||
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
@@ -1118,16 +1157,23 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
status_code=400,
|
||||
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
|
||||
)
|
||||
user = _consume_token(db, token, "reset")
|
||||
if user is None:
|
||||
spent = _consume_password_token(db, token)
|
||||
if spent is None:
|
||||
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
|
||||
user, kind = spent
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
|
||||
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
||||
# email — this rescues an account that registered but never clicked the verification link (it
|
||||
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
||||
# here: a reset proves email ownership, not that an admin approved the account.
|
||||
user.email_verified = True
|
||||
#
|
||||
# An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by
|
||||
# whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let
|
||||
# an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is
|
||||
# about. The person still confirms their address the normal way.
|
||||
if kind == "reset":
|
||||
user.email_verified = True
|
||||
bump_session_epoch(
|
||||
user
|
||||
) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||
@@ -1135,7 +1181,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.user_id == user.id,
|
||||
AuthToken.kind == "reset",
|
||||
AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
|
||||
AuthToken.used_at.is_(None),
|
||||
)
|
||||
).scalars():
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""E24 · the Home surface's HTTP shell.
|
||||
|
||||
Thin by construction: every decision — what counts as in progress, what fits a time budget, which
|
||||
tier the hero comes from — lives in `app/discover/`, so the scheduler, a future CLI or a test can ask
|
||||
the same questions without a request. This file validates, gates and serializes.
|
||||
|
||||
The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape (E5 S4): a
|
||||
landing page that fires four requests to answer one question would spend its whole budget on the
|
||||
thing it exists to remove.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user, require_human
|
||||
from app.db import get_db
|
||||
from app.discover import hero as hero_mod
|
||||
from app.discover import pins, rails
|
||||
from app.discover.budget import Budget, DEFAULT_BUDGET
|
||||
from app.models import ComfortPin, User
|
||||
from app.routes._common import owned_or_404
|
||||
|
||||
router = APIRouter(prefix="/api/discover", tags=["discover"])
|
||||
|
||||
|
||||
class PinIn(BaseModel):
|
||||
# From `pins.PIN_KINDS`, not a second hand-written list: the vocabulary, the schema columns and
|
||||
# this request contract have to move together, and re-typing them here is how they would not.
|
||||
kind: Literal[*pins.PIN_KINDS]
|
||||
# The id the rest of the API speaks for that kind: a video id, a channel id, a Plex rating_key,
|
||||
# or a playlist id. `pins.resolve_ref` does the translation to the row's internal key.
|
||||
ref: str
|
||||
|
||||
|
||||
class ReorderIn(BaseModel):
|
||||
ids: list[int]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_discover(
|
||||
budget: Budget = DEFAULT_BUDGET,
|
||||
seed: int = 0,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the Home page renders, in one pass.
|
||||
|
||||
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
|
||||
are not queried twice for a single page load."""
|
||||
resume = rails.continue_watching(db, user)
|
||||
shelf = pins.shelf(db, user)
|
||||
return {
|
||||
"budget": budget,
|
||||
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
|
||||
"continue_watching": resume,
|
||||
"comfort": shelf,
|
||||
}
|
||||
|
||||
|
||||
# How many already-shown cards a reroll may carry, matched to the client's own rolling window
|
||||
# (`SEEN_WINDOW` in `Home.tsx`). A cheap sanity clamp, and deliberately no more than that: `exclude`
|
||||
# never reaches SQL — `hero.pick` uses it only as set membership over an already-bounded candidate
|
||||
# pool (the resume rail's `RAIL_LIMIT` rows, the shelf, `SURPRISE_DRAW` surprise draws) — so there is
|
||||
# no scan for a crafted URL to unbound, and the set is built in full before this slice anyway. The
|
||||
# comment that claimed otherwise was corrected in round 2; the request line's own length is what
|
||||
# actually bounds a hostile `exclude`.
|
||||
MAX_EXCLUDE = 32
|
||||
|
||||
|
||||
@router.get("/pick")
|
||||
def get_pick(
|
||||
budget: Budget = DEFAULT_BUDGET,
|
||||
seed: int = 0,
|
||||
exclude: str = "",
|
||||
start: int = 0,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget.
|
||||
|
||||
One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the
|
||||
Plex half while the YouTube half stood still, and the button would look broken half the time.
|
||||
|
||||
`exclude` is a comma-separated list of `<source>:<id>` keys the user has already been shown. It
|
||||
is what makes the button honest: a seed alone only moves the surprise tier, so a user with
|
||||
something half-finished got the same card back every press.
|
||||
|
||||
`start` is the press count: it rotates which tier answers first, so consecutive presses offer a
|
||||
different KIND of answer instead of walking down the resume rail. Taken modulo the tier count in
|
||||
`hero.pick`, so any integer is valid and no value can 422 a button."""
|
||||
skip = {p for p in exclude.split(",") if p}
|
||||
return {
|
||||
"budget": budget,
|
||||
"hero": hero_mod.pick(
|
||||
db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE]), start=start
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pins")
|
||||
def list_pins(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The shelf on its own — for the shelf editor, which needs it without the rest of the page."""
|
||||
return {"items": pins.shelf(db, user)}
|
||||
|
||||
|
||||
@router.post("/pins")
|
||||
def add_pin(
|
||||
body: PinIn,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Pin something to the comfort shelf.
|
||||
|
||||
`require_human`, like every other personal-state write: the shared demo account must not be able
|
||||
to curate a communal shelf. Re-pinning something already on the shelf is a no-op that returns the
|
||||
existing pin rather than a 409 — the gesture is "make sure this is on my shelf", and the unique
|
||||
constraint is the mechanism, not the message."""
|
||||
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown item")
|
||||
column, value = resolved
|
||||
|
||||
existing = db.execute(
|
||||
select(ComfortPin).where(
|
||||
ComfortPin.user_id == user.id,
|
||||
getattr(ComfortPin, column) == value,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return {"ok": True, "id": existing.id, "created": False}
|
||||
|
||||
pin = ComfortPin(user_id=user.id, position=pins.next_position(db, user), **{column: value})
|
||||
db.add(pin)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# A concurrent pin of the same thing from a second tab. The unique constraint did its job;
|
||||
# the user's intent ("be on my shelf") is satisfied either way, so report the winner rather
|
||||
# than a 500 the user can do nothing with.
|
||||
db.rollback()
|
||||
winner = db.execute(
|
||||
select(ComfortPin).where(
|
||||
ComfortPin.user_id == user.id,
|
||||
getattr(ComfortPin, column) == value,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if winner is None:
|
||||
# Not the unique constraint, then: the only other way in is the target row being pruned
|
||||
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
||||
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
|
||||
# returns above — re-raising handed the user a 500 they could do nothing with.
|
||||
raise HTTPException(status_code=404, detail="Unknown item")
|
||||
return {"ok": True, "id": winner.id, "created": False}
|
||||
return {"ok": True, "id": pin.id, "created": True}
|
||||
|
||||
|
||||
@router.delete("/pins/{pin_id}")
|
||||
def remove_pin(
|
||||
pin_id: int,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pin = owned_or_404(db, ComfortPin, pin_id, user)
|
||||
db.delete(pin)
|
||||
db.commit()
|
||||
return {"deleted": pin_id}
|
||||
|
||||
|
||||
@router.post("/pins/reorder")
|
||||
def reorder_pins(
|
||||
body: ReorderIn,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Rewrite the shelf order from a full list of pin ids.
|
||||
|
||||
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
|
||||
tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not
|
||||
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
|
||||
deleted meanwhile should reorder what still exists, not fail wholesale."""
|
||||
owned = {p.id for p in pins.list_pins(db, user)}
|
||||
for position, pin_id in enumerate(i for i in body.ids if i in owned):
|
||||
db.execute(
|
||||
update(ComfortPin)
|
||||
.where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
|
||||
.values(position=position)
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -28,6 +28,7 @@ from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads import urlguard
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -65,7 +66,18 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
||||
if _VIDEO_ID.match(raw):
|
||||
return "youtube", raw
|
||||
if raw.startswith(("http://", "https://")):
|
||||
u = urlparse(raw)
|
||||
# `urlparse` raises ValueError on a malformed authority ("http://[::1/x"), and this runs
|
||||
# BEFORE the urlguard check that would otherwise turn it into a structured refusal — so
|
||||
# unguarded, that typo escapes as a 500. Answered in urlguard's own shape, because it is the
|
||||
# same typo class: ":99999" is caught there and localized, and one of the two arriving as a
|
||||
# bare English string would be exactly the split the canonical-patterns table forbids.
|
||||
try:
|
||||
u = urlparse(raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "url_blocked", "reason": urlguard.REASON_MALFORMED},
|
||||
) from exc
|
||||
host = u.netloc.lower()
|
||||
if "youtube.com" in host:
|
||||
if u.path.startswith(("/shorts/", "/embed/", "/live/")):
|
||||
@@ -352,6 +364,17 @@ def enqueue_download(
|
||||
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
source_kind, source_ref = resolve_source(body.source)
|
||||
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
|
||||
# ones pointing back into our own network BEFORE a job exists. The worker re-checks (DNS can
|
||||
# move between here and the download); see downloads/urlguard.py for what that does and does
|
||||
# not cover.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(source_ref)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
|
||||
) from e
|
||||
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
|
||||
display_name = (body.display_name or "").strip() or None
|
||||
try:
|
||||
@@ -532,7 +555,12 @@ def _clean_url(raw) -> str | None:
|
||||
return None
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
# The third site in this module handing user input to `urlparse`, which raises on a malformed
|
||||
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
if not parsed.netloc:
|
||||
return None
|
||||
return url[:2048]
|
||||
|
||||
@@ -3,6 +3,8 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
|
||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, and_
|
||||
@@ -20,10 +22,30 @@ from app.sync.playlists import (
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
|
||||
# SEC-14, same reasoning as the sync module's limiter: a playlist pull lists every playlist the
|
||||
# caller owns and then every item in each, against the instance-wide YouTube budget. Its own
|
||||
# limiter rather than a shared one — spending the manual subscription-import allowance must not
|
||||
# also close the playlist button, and vice versa.
|
||||
#
|
||||
# It guards EVERY route that spends under QuotaAction.PLAYLISTS_PULL, not just the bulk one: a
|
||||
# scripted loop over the single-playlist re-pull drains the same shared budget, and limiting one of
|
||||
# two routes with the same quota action is the asymmetric fix this codebase keeps re-learning.
|
||||
# Deliberately NOT on the push side (`push-plan` / `push`): those run on every push attempt,
|
||||
# including the no-op "already up to date" path, so a 3-per-10-minute allowance would refuse
|
||||
# legitimate work. Their exposure is tracked as SEC-18 instead.
|
||||
_playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
def _check_pull_cooldown(user: User) -> None:
|
||||
"""Shared gate so the two PLAYLISTS_PULL routes cannot drift apart again."""
|
||||
if not _playlist_pull_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
|
||||
|
||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
pl = db.get(Playlist, playlist_id)
|
||||
@@ -32,6 +54,16 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
return pl
|
||||
|
||||
|
||||
def _touch(pl: Playlist) -> None:
|
||||
"""Bump the playlist's `updated_at` for an edit that only writes `playlist_items`.
|
||||
|
||||
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
|
||||
reorder / move write the item rows instead — so without this the rail's "recently changed"
|
||||
sort would only ever see renames. The assignment is what makes the column mean "last time
|
||||
this list changed", which is what the rail promises the reader."""
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||
"""Append position for a new playlist in this user's rail."""
|
||||
return (
|
||||
@@ -71,6 +103,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
||||
)
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -88,6 +121,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
|
||||
db.delete(item)
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -109,6 +143,9 @@ def _summary(
|
||||
"item_count": count,
|
||||
"total_duration_seconds": total_duration,
|
||||
"cover_thumbnail": cover,
|
||||
# When this list last CHANGED (rename, add/remove/move/reorder) — the rail's
|
||||
# "recently changed first" sort reads it. See _touch for why it isn't automatic.
|
||||
"updated_at": pl.updated_at,
|
||||
}
|
||||
if has_video is not None:
|
||||
out["has_video"] = has_video
|
||||
@@ -284,6 +321,7 @@ def sync_youtube(
|
||||
status_code=403,
|
||||
detail="Connect YouTube (read access) to sync your playlists.",
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return sync_user_playlists(db, user)
|
||||
|
||||
@@ -304,6 +342,7 @@ def revert_youtube(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return repull_playlist(db, user, pl)
|
||||
@@ -498,6 +537,63 @@ def remove_item(
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
||||
class MoveItemIn(BaseModel):
|
||||
target_playlist_id: int
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/items/{video_id}/move")
|
||||
def move_item(
|
||||
playlist_id: int,
|
||||
video_id: str,
|
||||
body: MoveItemIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Move one video from a playlist into another of the caller's playlists, atomically.
|
||||
|
||||
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
|
||||
from the source — leaves the video in BOTH lists whenever the second call fails, which is
|
||||
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
|
||||
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
||||
(moving into a list that already has it is a removal, not a duplicate)."""
|
||||
src = _own_playlist(db, user, playlist_id)
|
||||
dst = _own_playlist(db, user, body.target_playlist_id)
|
||||
if src.id == dst.id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
||||
)
|
||||
item = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
# A concurrent edit (another tab, or a YouTube re-pull) already took it out.
|
||||
raise HTTPException(
|
||||
status_code=404, detail={"code": "playlist_move", "reason": "not_in_playlist"}
|
||||
)
|
||||
already = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if already is not None:
|
||||
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
|
||||
else:
|
||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
||||
# survives the move, and there is no window where the video belongs to neither list.
|
||||
item.playlist_id = dst.id
|
||||
item.position = _next_item_position(db, dst.id)
|
||||
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
|
||||
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
|
||||
recompute_dirty(db, src)
|
||||
recompute_dirty(db, dst)
|
||||
_touch(src)
|
||||
_touch(dst)
|
||||
db.commit()
|
||||
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
||||
|
||||
|
||||
class ReorderItemsIn(BaseModel):
|
||||
video_ids: list[str] = []
|
||||
|
||||
@@ -533,5 +629,6 @@ def reorder_items(
|
||||
it.position = pos
|
||||
pos += 1
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "count": pos}
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
)
|
||||
from app.models import PlexItem
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
@@ -25,21 +21,3 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||
def _norm_lang(lang: str | None) -> str:
|
||||
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
|
||||
return "hu" if lang == "hu" else "en"
|
||||
|
||||
|
||||
def _content_libs(db: Session) -> tuple[list[int], list[int]]:
|
||||
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
||||
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
||||
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5)."""
|
||||
libs = (
|
||||
db.execute(
|
||||
select(PlexLibrary).where(
|
||||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
|
||||
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
|
||||
return movie_lib_ids, show_lib_ids
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.plex.query import (
|
||||
apply_meta_filters,
|
||||
apply_movie_state_filter,
|
||||
apply_show_state_filter,
|
||||
content_libs,
|
||||
field_recheck,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
@@ -47,7 +48,7 @@ from app.plex.query import (
|
||||
vector_col,
|
||||
vector_match,
|
||||
)
|
||||
from app.routes.plex._shared import _content_libs, _norm_lang
|
||||
from app.routes.plex._shared import _norm_lang
|
||||
from app.utils import to_tsquery_str
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
@@ -109,7 +110,7 @@ def unified_library(
|
||||
offset = max(0, offset)
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||
|
||||
@@ -328,7 +329,7 @@ def facets(
|
||||
"duration_min": None,
|
||||
"duration_max": None,
|
||||
}
|
||||
all_movie_ids, all_show_ids = _content_libs(db)
|
||||
all_movie_ids, all_show_ids = content_libs(db)
|
||||
movie_ids = all_movie_ids if scope in ("movie", "both") else []
|
||||
show_ids = all_show_ids if scope in ("show", "both") else []
|
||||
if not movie_ids and not show_ids:
|
||||
|
||||
@@ -19,8 +19,7 @@ from app.plex import sync as plex_sync
|
||||
from app.plex.cards import movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import like_escape, runtime_by_key, top_art_keys
|
||||
from app.routes.plex._shared import _content_libs
|
||||
from app.plex.query import content_libs, like_escape, runtime_by_key, top_art_keys
|
||||
|
||||
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
||||
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||
@@ -128,7 +127,7 @@ def collections(
|
||||
# members (a collection lives in one library, so exactly one side has art) — a show collection would
|
||||
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
|
||||
# Runtime is movie-only (shows have no single duration).
|
||||
movie_ids, show_ids = _content_libs(db)
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
keys = [c.rating_key for c in rows]
|
||||
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
|
||||
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
|
||||
|
||||
@@ -27,12 +27,13 @@ from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
apply_show_state_filter,
|
||||
content_libs,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
top_art_keys,
|
||||
)
|
||||
from app.plex.tmdb import resolve_recs
|
||||
from app.routes.plex._shared import _content_libs, _norm_lang
|
||||
from app.routes.plex._shared import _norm_lang
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
@@ -43,7 +44,7 @@ SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows
|
||||
|
||||
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||||
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
|
||||
movie_ids, show_ids = _content_libs(db)
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
return (
|
||||
movie_ids if scope in ("movie", "both") else [],
|
||||
show_ids if scope in ("show", "both") else [],
|
||||
@@ -112,10 +113,11 @@ def _recently_added(
|
||||
else []
|
||||
)
|
||||
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
|
||||
# per-card state lookups stay batched (no N+1). The `(is not None, value)` key sorts a missing
|
||||
# timestamp last under reverse.
|
||||
# per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — the
|
||||
# E24 review found this one still on the older `(is not None, value)` form, i.e. one rule in two
|
||||
# spellings, with the two comments explaining it differently.
|
||||
tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows]
|
||||
tagged.sort(key=lambda t: (t[0] is not None, t[0]), reverse=True)
|
||||
tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True)
|
||||
top = tagged[:RAIL_LIMIT]
|
||||
# Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never
|
||||
# share one), so the merge doesn't depend on object identity staying alive.
|
||||
@@ -170,7 +172,7 @@ def _recently_watched(
|
||||
)
|
||||
for ep, st, sh_title, sh_rk in db.execute(eq).all():
|
||||
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
|
||||
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
@@ -234,7 +236,7 @@ def _continue_watching(
|
||||
for sh, resumed_at in srows:
|
||||
rows.append((resumed_at, show_card(sh, "in_progress")))
|
||||
|
||||
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ from app.models import (
|
||||
from app.plex import rollup
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import movie_card, show_card
|
||||
from app.plex.query import split_csv
|
||||
from app.routes.plex._shared import _content_libs
|
||||
from app.plex.query import content_libs, split_csv
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
@@ -71,7 +70,7 @@ def people_cards(
|
||||
wanted = split_csv(names)[:16]
|
||||
if not wanted:
|
||||
return {"people": []}
|
||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||
photos = {
|
||||
@@ -185,7 +184,7 @@ def person_detail(
|
||||
and _should_schedule_enrich(person.id)
|
||||
):
|
||||
background_tasks.add_task(_enrich_person_bg, person.id)
|
||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||
|
||||
movies: list[dict] = []
|
||||
if movie_lib_ids:
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import state, sysconfig
|
||||
from app.utils import canonical_email
|
||||
from app.auth import PASSWORD_MIN_LEN
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
@@ -70,7 +71,13 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
||||
)
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
||||
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
||||
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
||||
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
|
||||
user = db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(email=email, role="admin")
|
||||
db.add(user)
|
||||
|
||||
@@ -7,12 +7,20 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
# SEC-14: the YouTube API budget is SHARED across the whole instance, and a subscription import
|
||||
# walks every page of the caller's subscriptions — so one user holding down a manual sync button can
|
||||
# spend the day's units for everyone. Keyed by user id, not client IP: the cost belongs to the
|
||||
# account, and the same person on a second device is the same drain. 3 per 10 minutes leaves manual
|
||||
# re-syncing entirely usable (the scheduler does the routine work anyway) while removing the loop.
|
||||
_sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||
@@ -22,6 +30,8 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
|
||||
status_code=403,
|
||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||
)
|
||||
if not _sync_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||
result = import_subscriptions(db, user)
|
||||
result["quota_used_estimate"] = m.used
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""E12 · S3 — the route-level halves of SEC-12 and SEC-14.
|
||||
|
||||
`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually
|
||||
consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a
|
||||
manual sync trigger stops spending the instance-wide YouTube budget when held down.
|
||||
|
||||
DB-backed (the `as_user` fixture needs real rows) — skipped when no database is reachable.
|
||||
"""
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def test_enqueueing_a_loopback_url_is_refused_with_a_structured_detail(
|
||||
db, as_user, make_user, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket, "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("127.0.0.1", 0))]
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "http://redis.internal:6379/x"})
|
||||
assert r.status_code == 422
|
||||
# The shape matters as much as the refusal: a bare English string under a bilingual UI is the
|
||||
# thing the canonical-patterns table forbids.
|
||||
assert r.json()["detail"] == {"code": "url_blocked", "reason": urlguard.REASON_PRIVATE}
|
||||
|
||||
|
||||
def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# Mutation check for the test above: with a public answer the request must fail LATER (or
|
||||
# succeed), never with url_blocked — otherwise the guard would be refusing everything and the
|
||||
# first test would still pass.
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket,
|
||||
"getaddrinfo",
|
||||
lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"})
|
||||
detail = r.json().get("detail") if r.status_code >= 400 else None
|
||||
assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked")
|
||||
|
||||
|
||||
def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# A YouTube job resolves to a fixed public host from an 11-char id, so there is no
|
||||
# user-controlled destination — resolving one would be pure latency on every enqueue.
|
||||
def boom(*a, **kw):
|
||||
raise AssertionError("the URL guard resolved DNS for a YouTube source")
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"})
|
||||
|
||||
|
||||
def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch):
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
# Neither the YouTube grant nor the import itself is under test — the limiter is.
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post("/api/sync/subscriptions").status_code for _ in range(4)]
|
||||
# 3 per 10 minutes: the first three pass, the fourth is refused.
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
assert c.post("/api/sync/subscriptions").json()["detail"] == {"code": "sync_cooldown"}
|
||||
|
||||
|
||||
def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch):
|
||||
# Keyed by user id on purpose: the shared YouTube budget is spent per account, and one user
|
||||
# exhausting their allowance must not lock everyone else out of their own sync button.
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
a, b = make_user(), make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
ca = as_user(a)
|
||||
for _ in range(4):
|
||||
ca.post("/api/sync/subscriptions")
|
||||
assert ca.post("/api/sync/subscriptions").status_code == 429
|
||||
assert as_user(b).post("/api/sync/subscriptions").status_code == 200
|
||||
|
||||
|
||||
def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_user, monkeypatch):
|
||||
# SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key
|
||||
# as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one
|
||||
# route over. Both must draw on the one allowance.
|
||||
from app.models import Playlist
|
||||
from app.routes import playlists as plroutes
|
||||
|
||||
user = make_user()
|
||||
pl = Playlist(user_id=user.id, name="Linked", source="youtube", yt_playlist_id="PL123")
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
|
||||
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
# One allowance, not one each: the bulk route is now on cooldown too.
|
||||
r = c.post("/api/playlists/sync-youtube")
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == {"code": "sync_cooldown"}
|
||||
@@ -0,0 +1,942 @@
|
||||
"""E24 S1 — the Home surface's read-model.
|
||||
|
||||
Covers the four things that can silently rot here: the time-budget bands (pure), the cross-source
|
||||
resume rail's merge order, the hero's tier ladder, and the comfort shelf (its schema-level one-of
|
||||
invariant, its cascade, and the resolved `next` that makes one click enough).
|
||||
|
||||
DB lane (`db` fixture) except for the budget block — skipped without Postgres.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.discover import hero, pins, queries, rails
|
||||
from app.discover.budget import BANDS, fits, remaining_seconds
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import leaf_card
|
||||
from app.models import (
|
||||
BlockedChannel,
|
||||
Channel,
|
||||
ComfortPin,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
Subscription,
|
||||
User,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# ── budget (pure) ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bands_cover_every_runtime_and_overlap_deliberately():
|
||||
"""Every runtime has a home in `any`, and the bands overlap so "30 perc" is not answered with a
|
||||
two-minute Short just because it fits under the cap."""
|
||||
assert fits(3, "any") and fits(10_000, "any")
|
||||
assert fits(2 * 60, "short") and not fits(2 * 60, "medium") # a Short is not a 30-minute answer
|
||||
assert fits(30 * 60, "medium") and not fits(30 * 60, "short")
|
||||
assert fits(110 * 60, "long") and not fits(110 * 60, "medium")
|
||||
|
||||
|
||||
def test_unknown_duration_only_fits_the_unbounded_band():
|
||||
"""We cannot promise an unknown-length item finishes inside ten minutes, so it is not offered as
|
||||
an answer to a bounded question — the one control on the page has to mean what it says."""
|
||||
for budget in BANDS:
|
||||
assert fits(None, budget) is (budget == "any")
|
||||
|
||||
|
||||
def test_budget_is_matched_against_what_is_LEFT():
|
||||
"""A 90-minute film with 8 minutes to go is a ten-minute answer."""
|
||||
assert remaining_seconds(90 * 60, 82 * 60) == 8 * 60
|
||||
assert fits(remaining_seconds(90 * 60, 82 * 60), "short")
|
||||
assert not fits(remaining_seconds(90 * 60, 0), "short")
|
||||
assert remaining_seconds(None, 30) is None
|
||||
assert remaining_seconds(600, 900) == 0 # a position past the end never goes negative
|
||||
|
||||
|
||||
# ── fixtures ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _seed_yt(db) -> tuple[User, Channel]:
|
||||
"""A subscribed channel with three videos of distinct lengths, so a budget can pick between them."""
|
||||
user = User(email="discover-e24@example.com")
|
||||
ch = Channel(id="UCe24", title="Comfort Channel", handle="@comfort")
|
||||
db.add_all([user, ch])
|
||||
db.flush()
|
||||
db.add(Subscription(user_id=user.id, channel_id=ch.id))
|
||||
db.add_all(
|
||||
[
|
||||
Video(
|
||||
id="vshort",
|
||||
channel_id=ch.id,
|
||||
title="Ten minute video",
|
||||
duration_seconds=9 * 60,
|
||||
published_at=NOW - timedelta(days=1),
|
||||
),
|
||||
Video(
|
||||
id="vmed",
|
||||
channel_id=ch.id,
|
||||
title="Half hour video",
|
||||
duration_seconds=30 * 60,
|
||||
published_at=NOW - timedelta(days=2),
|
||||
),
|
||||
Video(
|
||||
id="vlong",
|
||||
channel_id=ch.id,
|
||||
title="Feature length video",
|
||||
duration_seconds=95 * 60,
|
||||
published_at=NOW - timedelta(days=3),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return user, ch
|
||||
|
||||
|
||||
def _seed_plex(db, user: User) -> tuple[PlexItem, PlexShow, PlexItem]:
|
||||
lib_m = PlexLibrary(plex_key="90", title="Movies", kind="movie", enabled=True)
|
||||
lib_s = PlexLibrary(plex_key="91", title="Shows", kind="show", enabled=True)
|
||||
db.add_all([lib_m, lib_s])
|
||||
db.flush()
|
||||
movie = PlexItem(
|
||||
rating_key="pm1",
|
||||
library_id=lib_m.id,
|
||||
kind="movie",
|
||||
title="A Movie",
|
||||
duration_s=100 * 60,
|
||||
playable="direct",
|
||||
)
|
||||
show = PlexShow(rating_key="ps1", library_id=lib_s.id, title="A Show", child_count=1)
|
||||
db.add_all([movie, show])
|
||||
db.flush()
|
||||
ep1 = PlexItem(
|
||||
rating_key="ps1e1",
|
||||
library_id=lib_s.id,
|
||||
kind="episode",
|
||||
title="Ep 1",
|
||||
show_id=show.id,
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
duration_s=45 * 60,
|
||||
)
|
||||
ep2 = PlexItem(
|
||||
rating_key="ps1e2",
|
||||
library_id=lib_s.id,
|
||||
kind="episode",
|
||||
title="Ep 2",
|
||||
show_id=show.id,
|
||||
season_number=1,
|
||||
episode_number=2,
|
||||
duration_s=45 * 60,
|
||||
)
|
||||
db.add_all([ep1, ep2])
|
||||
db.commit()
|
||||
return movie, show, ep1
|
||||
|
||||
|
||||
def _enable_plex(db):
|
||||
from app import sysconfig
|
||||
|
||||
sysconfig.set_value(db, "plex_enabled", True)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── continue watching ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_continue_watching_merges_both_sources_by_recency(db):
|
||||
"""One rail, not two lists side by side — and ordered by when the user last touched each thing,
|
||||
across sources. Two side-by-side rails would just move the decision up a level."""
|
||||
user, _ = _seed_yt(db)
|
||||
movie, _, _ = _seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
db.add_all(
|
||||
[
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW - timedelta(hours=5),
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=movie.id,
|
||||
status="new",
|
||||
position_seconds=600,
|
||||
progress_updated_at=NOW - timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
got = rails.continue_watching(db, user)
|
||||
assert [(c["source"], c["id"]) for c in got] == [("plex", "pm1"), ("youtube", "vmed")]
|
||||
assert got[0]["remaining_seconds"] == 100 * 60 - 600
|
||||
|
||||
|
||||
def test_continue_watching_is_youtube_only_when_plex_is_off(db):
|
||||
"""Plex is a deployment toggle; with it off the surface says nothing about Plex at all."""
|
||||
user, _ = _seed_yt(db)
|
||||
movie, _, _ = _seed_plex(db, user)
|
||||
db.add_all(
|
||||
[
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=movie.id,
|
||||
status="new",
|
||||
position_seconds=600,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
assert {c["source"] for c in rails.continue_watching(db, user)} == {"youtube"}
|
||||
|
||||
|
||||
def test_the_resume_rail_obeys_the_channel_blocklist(db):
|
||||
"""Blocking a channel does not touch `video_states`, so a half-watched video from it survives
|
||||
with its resume position — and this rail builds its own SELECT instead of going through
|
||||
`filtered_query`. "Never show me this channel" has to outrank "you started it", or the landing
|
||||
page answers "what should I watch" with the thing the user has just banned."""
|
||||
user, ch = _seed_yt(db)
|
||||
db.add(
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert [c["id"] for c in rails.continue_watching(db, user)] == ["vmed"]
|
||||
|
||||
db.add(BlockedChannel(user_id=user.id, channel_id=ch.id))
|
||||
db.commit()
|
||||
assert rails.continue_watching(db, user) == []
|
||||
# And so the hero, whose resume tier draws from that rail, has nothing to offer either.
|
||||
assert hero.pick(db, user, "any", 0) is None
|
||||
|
||||
|
||||
def test_a_null_timestamp_sorts_last(db):
|
||||
"""A missing progress timestamp sorts last rather than 500ing the landing page.
|
||||
|
||||
Narrower than it was written to be, and the epic-end review corrected it: the previous key,
|
||||
`(ts is not None, ts)`, does NOT raise on two nulls — tuple comparison finds the first UNEQUAL
|
||||
element via `==`, so `None < None` is never reached, and this test passes under that key too.
|
||||
What it does discriminate is the naive `key=lambda r: r[0]`, which really does raise. See
|
||||
`plex.rollup.NO_TS` for the corrected rationale."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add_all(
|
||||
[
|
||||
VideoState(
|
||||
user_id=user.id, video_id="vmed", status="new", position_seconds=1
|
||||
), # no progress_updated_at
|
||||
VideoState(
|
||||
user_id=user.id, video_id="vlong", status="new", position_seconds=1
|
||||
), # nor here
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vshort",
|
||||
status="new",
|
||||
position_seconds=1,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
got = rails.continue_watching(db, user)
|
||||
assert got[0]["id"] == "vshort"
|
||||
assert len(got) == 3
|
||||
|
||||
|
||||
# ── hero ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_hero_prefers_resume_and_labels_the_tier(db):
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
got = hero.pick(db, user, "any", seed=1)
|
||||
assert got["tier"] == "resume"
|
||||
assert got["card"]["id"] == "vmed"
|
||||
|
||||
|
||||
def test_hero_skips_a_resume_that_does_not_fit_the_budget(db):
|
||||
""" "I have ten minutes" must not be answered with the 80 minutes left on a film, even though
|
||||
that film is the most recent thing the user touched."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vlong",
|
||||
status="new",
|
||||
position_seconds=60,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
got = hero.pick(db, user, "short", seed=1)
|
||||
assert got["tier"] == "surprise"
|
||||
assert got["card"]["duration_seconds"] <= BANDS["short"][1]
|
||||
|
||||
|
||||
def test_hero_falls_back_to_the_shelf_before_guessing(db):
|
||||
"""The user's own declared fallback beats anything we could infer."""
|
||||
user, ch = _seed_yt(db)
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
|
||||
db.commit()
|
||||
got = hero.pick(db, user, "medium", seed=1)
|
||||
assert got["tier"] == "comfort"
|
||||
assert got["card"]["id"] == "vmed"
|
||||
|
||||
|
||||
def test_hero_resolves_a_show_to_the_episode_that_would_play(db):
|
||||
"""A show has no Play button. The hero of a partially-watched show is its next episode."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, show, ep1 = _seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
db.add(
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep1.id,
|
||||
status="watched",
|
||||
watched_at=NOW,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
got = hero.pick(db, user, "any", seed=1)
|
||||
assert got["tier"] == "resume"
|
||||
assert got["card"]["kind"] == "episode"
|
||||
assert got["card"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays
|
||||
|
||||
|
||||
def test_hero_is_none_when_nothing_fits(db):
|
||||
"""An empty answer is an honest answer: the page renders its own empty state rather than being
|
||||
handed a card that does not answer what was asked. Here the whole library is under 40 minutes,
|
||||
so "egy este" has nothing to offer."""
|
||||
user, ch = _seed_yt(db)
|
||||
db.execute(text("DELETE FROM videos WHERE id = 'vlong'"))
|
||||
db.commit()
|
||||
assert hero.pick(db, user, "long", seed=1) is None
|
||||
|
||||
|
||||
def test_surprise_is_reproducible_for_a_seed(db):
|
||||
"""The reroll button depends on this: one seed drives both sources, so the same seed must give
|
||||
the same answer and a different seed must be free to give another."""
|
||||
user, _ = _seed_yt(db)
|
||||
a = rails.surprise(db, user, "any", seed=7, limit=3)
|
||||
b = rails.surprise(db, user, "any", seed=7, limit=3)
|
||||
assert [c["id"] for c in a] == [c["id"] for c in b]
|
||||
seeds = {tuple(c["id"] for c in rails.surprise(db, user, "any", s, 3)) for s in range(12)}
|
||||
assert len(seeds) > 1 # the seed actually reaches the ordering
|
||||
|
||||
|
||||
def test_surprise_never_offers_something_already_started(db):
|
||||
"""The resume tier may have skipped an item because its REMAINING time did not fit; handing the
|
||||
same item back as "Meglepetés" — where the full runtime does fit — would contradict the question
|
||||
the user just answered."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vlong",
|
||||
status="new",
|
||||
position_seconds=90 * 60,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert "vlong" not in {c["id"] for c in rails.surprise(db, user, "long", seed=3, limit=5)}
|
||||
|
||||
|
||||
def test_the_seed_decides_which_source_leads(db):
|
||||
"""With a pool of one, a fixed source order would mean the Plex library could never answer "egy
|
||||
este" whenever a YouTube video also fit — the cross-source promise would quietly be a YouTube
|
||||
promise."""
|
||||
user, _ = _seed_yt(db)
|
||||
_seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
leaders = {rails.surprise(db, user, "any", s, limit=1)[0]["source"] for s in range(6)}
|
||||
assert leaders == {"youtube", "plex"}
|
||||
|
||||
|
||||
def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db):
|
||||
"""A new seed alone only re-draws the SURPRISE tier — the resume tier deterministically returns
|
||||
the most recently progressed thing that fits, so without an exclusion the reroll handed the same
|
||||
card straight back and the button looked dead (found in the browser, 2026-09-06)."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add_all(
|
||||
[
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vshort",
|
||||
status="new",
|
||||
position_seconds=60,
|
||||
progress_updated_at=NOW - timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
first = hero.pick(db, user, "any", seed=1)
|
||||
assert first["card"]["id"] == "vmed"
|
||||
# Same seed, same everything — only the exclusion differs.
|
||||
second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"})
|
||||
assert second["card"]["id"] == "vshort"
|
||||
|
||||
|
||||
def test_the_reroll_rotates_which_tier_answers(db):
|
||||
"""Measured 2026-09-06: exclusion alone left the button walking down the resume rail (20 rows)
|
||||
and cycling nine cards, every one of them `resume` — the shelf and the surprise tier were
|
||||
unreachable by pressing it. `start` is the press count, so consecutive presses offer a different
|
||||
KIND of answer."""
|
||||
user, _ = _seed_yt(db)
|
||||
# One of each: something half-finished, something pinned, and a third video for the draw.
|
||||
db.add(
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vlong", position=0))
|
||||
db.commit()
|
||||
tiers = [hero.pick(db, user, "any", seed=1, start=n)["tier"] for n in range(4)]
|
||||
assert tiers == ["resume", "comfort", "surprise", "resume"]
|
||||
|
||||
|
||||
def test_the_rotation_still_falls_through_an_empty_tier(db):
|
||||
"""A press must not come back empty just because the tier it happened to lead with has nothing.
|
||||
Here there is no shelf at all, so the comfort-led press has to answer from somewhere else."""
|
||||
user, _ = _seed_yt(db)
|
||||
assert db.execute(select(func.count()).select_from(ComfortPin)).scalar_one() == 0
|
||||
got = hero.pick(db, user, "any", seed=1, start=1)
|
||||
assert got is not None and got["tier"] != "comfort"
|
||||
|
||||
|
||||
def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db):
|
||||
"""The user rerolled past the SHOW they were shown; handing back its next episode under a
|
||||
different title would be the same suggestion wearing a hat."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, show, ep1 = _seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
db.add(
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep1.id,
|
||||
status="new",
|
||||
position_seconds=120,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
shown = hero.pick(db, user, "any", seed=1)
|
||||
assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode
|
||||
# Excluding what the USER saw — the show — must move the tier on, not return the episode again.
|
||||
after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"})
|
||||
assert after is None or after["card"]["id"] != "ps1e1"
|
||||
|
||||
|
||||
# ── comfort shelf ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_pin_must_reference_exactly_one_thing(db):
|
||||
"""The union invariant lives in the schema, not in a comment at every insert site."""
|
||||
user, ch = _seed_yt(db)
|
||||
for kwargs in ({}, {"video_id": "vmed", "channel_id": ch.id}):
|
||||
db.add(ComfortPin(user_id=user.id, position=0, **kwargs))
|
||||
with pytest.raises(IntegrityError):
|
||||
db.flush()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_the_same_thing_cannot_be_pinned_twice(db):
|
||||
"""The per-kind unique constraints are what make the route's concurrent-tab path safe (it catches
|
||||
`IntegrityError` precisely because of them) — and the migration drift check filters constraint
|
||||
diffs out as noise, so nothing else would notice them going missing. The route's own dedupe
|
||||
returns early without ever reaching the DB, so only this test exercises them."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
|
||||
db.commit()
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=1))
|
||||
with pytest.raises(IntegrityError):
|
||||
db.flush()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_deleting_the_pinned_row_removes_the_pin(db):
|
||||
"""A film dropped from the library must not leave a dead tile on the one surface whose whole job
|
||||
is to be reliable — which is why the shelf holds foreign keys, not strings."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
|
||||
db.commit()
|
||||
db.execute(text("DELETE FROM videos WHERE id = 'vmed'"))
|
||||
db.commit()
|
||||
assert db.execute(select(func.count()).select_from(ComfortPin)).scalar() == 0
|
||||
|
||||
|
||||
def test_shelf_resolves_a_channel_to_its_newest_unwatched_video(db):
|
||||
""" "Put that channel on" has to be one click, so the tile carries what would play."""
|
||||
user, ch = _seed_yt(db)
|
||||
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0))
|
||||
db.commit()
|
||||
(card,) = pins.shelf(db, user)
|
||||
assert card["kind"] == "channel"
|
||||
assert card["unwatched_count"] == 3
|
||||
assert card["next"]["id"] == "vshort" # newest
|
||||
|
||||
|
||||
def test_shelf_resolves_a_playlist_to_its_first_unwatched_item(db):
|
||||
user, _ = _seed_yt(db)
|
||||
pl = Playlist(user_id=user.id, name="Elalvós")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
PlaylistItem(playlist_id=pl.id, video_id="vshort", position=0),
|
||||
PlaylistItem(playlist_id=pl.id, video_id="vmed", position=1),
|
||||
]
|
||||
)
|
||||
db.add(VideoState(user_id=user.id, video_id="vshort", status="watched", watched_at=NOW))
|
||||
db.add(ComfortPin(user_id=user.id, playlist_id=pl.id, position=0))
|
||||
db.commit()
|
||||
(card,) = pins.shelf(db, user)
|
||||
assert card["kind"] == "playlist"
|
||||
assert card["item_count"] == 2
|
||||
assert card["next"]["id"] == "vmed"
|
||||
|
||||
|
||||
def test_a_pinned_video_survives_leaving_the_feed_scope(db):
|
||||
"""The shelf is the fallback surface. An entry silently vanishing because the user unsubscribed
|
||||
from the channel is exactly the failure it must not have."""
|
||||
user, ch = _seed_yt(db)
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
|
||||
db.commit()
|
||||
db.execute(delete(Subscription))
|
||||
db.commit()
|
||||
(card,) = pins.shelf(db, user)
|
||||
assert card["id"] == "vmed"
|
||||
assert card["next"]["id"] == "vmed"
|
||||
|
||||
|
||||
def test_the_plex_surprise_obeys_the_time_budget(db):
|
||||
"""The one control on the page has to mean what it says: a 100-minute film is not an answer to
|
||||
"10 perc". The YouTube half of this rule has had its own test since S1; the Plex half's duration
|
||||
bounds could be deleted with the whole suite still green, because every test that reached
|
||||
`plex_surprise` passed budget="any"."""
|
||||
user, _ = _seed_yt(db)
|
||||
_seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
drawn = lambda budget: { # noqa: E731
|
||||
(c["source"], c["id"]) for s in range(6) for c in rails.surprise(db, user, budget, s, 5)
|
||||
}
|
||||
assert ("plex", "pm1") not in drawn("short")
|
||||
# …and it IS offered where it fits, so this cannot pass by the rail being empty.
|
||||
assert ("plex", "pm1") in drawn("long")
|
||||
|
||||
|
||||
def test_the_plex_surprise_never_offers_something_already_started(db):
|
||||
"""A half-watched film is not a surprise, and the resume tier has already had its chance at it.
|
||||
The mirror of `test_surprise_never_offers_something_already_started` for the other source — the
|
||||
asymmetric half of one principle, which the house rules say to fix everywhere."""
|
||||
user, _ = _seed_yt(db)
|
||||
movie, _, _ = _seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
db.add(
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=movie.id,
|
||||
status="new",
|
||||
position_seconds=600,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
drawn = {
|
||||
(c["source"], c["id"]) for s in range(6) for c in rails.surprise(db, user, "long", s, 5)
|
||||
}
|
||||
assert ("plex", "pm1") not in drawn
|
||||
|
||||
|
||||
def test_a_new_pin_lands_at_the_end_even_after_a_hole(db):
|
||||
"""Positions are never renumbered on removal — `remove_pin` deletes the row, and every FK cascade
|
||||
silently removes a pin when its target disappears — so the append position has to come from
|
||||
`max(position)`, not from the row count. With a count, the first hole drops the next pin into the
|
||||
MIDDLE of the shelf and the second makes two pins claim one slot."""
|
||||
user, ch = _seed_yt(db)
|
||||
db.add_all(
|
||||
[
|
||||
ComfortPin(user_id=user.id, video_id="vshort", position=0),
|
||||
ComfortPin(user_id=user.id, video_id="vmed", position=1),
|
||||
ComfortPin(user_id=user.id, video_id="vlong", position=2),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
db.execute(delete(ComfortPin).where(ComfortPin.video_id.in_(("vshort", "vmed"))))
|
||||
db.commit()
|
||||
|
||||
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=pins.next_position(db, user)))
|
||||
db.commit()
|
||||
assert [c["kind"] for c in pins.shelf(db, user)] == ["video", "channel"]
|
||||
positions = (
|
||||
db.execute(select(ComfortPin.position).order_by(ComfortPin.position)).scalars().all()
|
||||
)
|
||||
assert len(set(positions)) == len(positions) # and no two tiles claim the same slot
|
||||
|
||||
|
||||
def test_a_self_playing_pin_does_not_reference_itself(db):
|
||||
"""`next` must be a distinct dict even when the pin plays itself. Aliasing the card into its own
|
||||
`next` is a cycle, and the serializer turns the whole shelf into a 500 over it."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
|
||||
db.commit()
|
||||
(card,) = pins.shelf(db, user)
|
||||
assert card["next"] is not card
|
||||
assert "next" not in card["next"]
|
||||
|
||||
|
||||
def test_the_resume_rail_says_what_would_actually_play(db):
|
||||
"""A show tile that only opens the show page moves the decision instead of removing it — the
|
||||
click lands on six seasons to choose between, which is the paralysis this whole surface exists
|
||||
for. The shelf and the hero already resolved; this rail did not (reported 2026-09-06). The tile
|
||||
stays the show (its artwork is what the user recognises); `next` is what starts."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, show, ep1 = _seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
db.add(
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep1.id,
|
||||
status="watched",
|
||||
watched_at=NOW,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
rail = rails.continue_watching(db, user)
|
||||
tile = next(c for c in rail if c["kind"] == "show")
|
||||
assert tile["id"] == "ps1" # still the show
|
||||
assert tile["next"]["kind"] == "episode"
|
||||
assert tile["next"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays
|
||||
|
||||
|
||||
def test_only_a_show_row_pays_for_a_resolution(db):
|
||||
"""`next` is the answer to "a show has no Play button". Attaching it to a movie or a video would
|
||||
both cost a query for nothing and re-open the self-reference cycle the shelf already hit.
|
||||
|
||||
The states are what make this test exist: without them every rail query inner-joins its way to
|
||||
zero rows, the loop body never runs, and the assertion is vacuous — which is exactly how the
|
||||
epic-end review found it."""
|
||||
user, _ = _seed_yt(db)
|
||||
movie, _, ep1 = _seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
db.add_all(
|
||||
[
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=movie.id,
|
||||
status="new",
|
||||
position_seconds=600,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep1.id,
|
||||
status="new",
|
||||
position_seconds=120,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
rail = rails.continue_watching(db, user)
|
||||
# A video, a movie and a show — one row of each kind that can appear here, so the assertion below
|
||||
# actually exercises both sides of the equality.
|
||||
assert {c["kind"] for c in rail} == {"video", "movie", "show"}
|
||||
for card in rail:
|
||||
assert ("next" in card) == (card["kind"] == "show"), card["id"]
|
||||
|
||||
|
||||
def test_next_episode_takes_an_episode_with_no_state_row_at_all(db):
|
||||
"""The NULL trap in the SQL rewrite: `status NOT IN ('watched','hidden')` is UNKNOWN — not true —
|
||||
for an episode the user has never touched, so a bare filter drops exactly the common case and the
|
||||
rail would offer episode 1 of a show already half-watched."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, show, ep1 = _seed_plex(db, user)
|
||||
db.add(PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW))
|
||||
db.commit() # ep2 has NO PlexState row
|
||||
assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2"
|
||||
|
||||
|
||||
def test_a_finished_show_starts_again_from_the_top(db):
|
||||
"""Nothing unwatched left is not "nothing to play" — it is "watch it again"."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, show, ep1 = _seed_plex(db, user)
|
||||
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one()
|
||||
db.add_all(
|
||||
[
|
||||
PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW),
|
||||
PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e1"
|
||||
|
||||
|
||||
def test_next_episode_agrees_with_the_show_page_when_two_are_in_progress(db):
|
||||
"""One show, one answer. `plex.rollup.rollup` — which drives the show page's Resume button —
|
||||
takes the LAST in-progress episode in broadcast order; taking the FIRST here gave the same show
|
||||
two different "continue" answers, with the rail ranking it by its most recent touch and then
|
||||
offering the episode abandoned weeks earlier. Two in-progress episodes are not exotic: a scrub to
|
||||
the end leaves a stale `status='new', position>0` row until the daily reconcile."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, show, ep1 = _seed_plex(db, user)
|
||||
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one()
|
||||
db.add_all(
|
||||
[
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep1.id,
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW - timedelta(days=9),
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep2.id,
|
||||
status="new",
|
||||
position_seconds=600,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
got = queries.next_episode(db, user.id, show.id)
|
||||
assert got["id"] == "ps1e2"
|
||||
# Asserted against the other implementation rather than against a literal, so the two cannot
|
||||
# drift apart again without this failing.
|
||||
ordered = [
|
||||
leaf_card(ep, db.execute(select(PlexState).where(PlexState.item_id == ep.id)).scalar_one())
|
||||
for ep in (ep1, ep2)
|
||||
]
|
||||
assert rollup.rollup(ordered)["resume"]["id"] == got["id"]
|
||||
|
||||
|
||||
def test_next_episode_prefers_the_one_in_progress(db):
|
||||
"""In progress beats "next unwatched": resuming is cheaper than starting."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, show, ep1 = _seed_plex(db, user)
|
||||
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one()
|
||||
db.add_all(
|
||||
[
|
||||
PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW),
|
||||
PlexState(user_id=user.id, item_id=ep2.id, status="new", position_seconds=120),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2"
|
||||
|
||||
|
||||
# ── HTTP surface ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_discover_endpoint_answers_in_one_pass(db, as_user):
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
r = as_user(user).get("/api/discover?budget=medium")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["budget"] == "medium"
|
||||
assert body["hero"]["tier"] == "resume"
|
||||
assert [c["id"] for c in body["continue_watching"]] == ["vmed"]
|
||||
assert body["comfort"] == []
|
||||
|
||||
|
||||
def test_an_unknown_budget_is_refused_rather_than_silently_widened(db, as_user):
|
||||
user, _ = _seed_yt(db)
|
||||
assert as_user(user).get("/api/discover?budget=whenever").status_code == 422
|
||||
|
||||
|
||||
def test_pinning_twice_is_the_same_shelf(db, as_user):
|
||||
"""The gesture is "make sure this is on my shelf", so a re-pin is a no-op, not a 409."""
|
||||
user, _ = _seed_yt(db)
|
||||
c = as_user(user)
|
||||
first = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()
|
||||
second = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()
|
||||
assert first["created"] is True and second["created"] is False
|
||||
assert first["id"] == second["id"]
|
||||
assert len(c.get("/api/discover/pins").json()["items"]) == 1
|
||||
|
||||
|
||||
def test_pinning_a_plex_show_resolves_to_the_episode_that_would_play(db, as_user):
|
||||
"""Two of the five pinnable kinds are Plex, and the rating_key → internal-id translation is
|
||||
theirs alone — a wrong column or a wrong model in either arm pins the wrong row, or 404s every
|
||||
Plex pin. Neither arm had a test before the epic-end review."""
|
||||
user, _ = _seed_yt(db)
|
||||
_, _, ep1 = _seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
db.add(PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW))
|
||||
db.commit()
|
||||
c = as_user(user)
|
||||
assert c.post("/api/discover/pins", json={"kind": "plex_show", "ref": "ps1"}).status_code == 200
|
||||
(tile,) = c.get("/api/discover/pins").json()["items"]
|
||||
assert (tile["kind"], tile["id"]) == ("show", "ps1")
|
||||
assert tile["next"]["id"] == "ps1e2" # ep1 is finished
|
||||
|
||||
|
||||
def test_pinning_a_plex_movie_plays_itself_without_a_cycle(db, as_user):
|
||||
"""The self-reference that once 500'd the whole shelf, on the other leaf kind: `next` has to be a
|
||||
distinct dict, not the tile aliased into itself. Covered for a video since S1, never for a movie."""
|
||||
user, _ = _seed_yt(db)
|
||||
_seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
c = as_user(user)
|
||||
assert c.post("/api/discover/pins", json={"kind": "plex_item", "ref": "pm1"}).status_code == 200
|
||||
(tile,) = c.get("/api/discover/pins").json()["items"]
|
||||
assert tile["id"] == "pm1" and tile["next"]["id"] == "pm1"
|
||||
|
||||
|
||||
def test_the_reroll_endpoint_parses_its_whole_exclusion_list_and_its_press_count(db, as_user):
|
||||
"""The reroll's behaviour is covered at the domain level; the ROUTE's query-string translation —
|
||||
the comma split and the `start` passthrough — was not covered at all, so a change there could
|
||||
hand back the same card on every press with the suite green. Excluding all three candidates is
|
||||
what proves the whole list parsed: a delimiter change leaves one unsplit string in the set, and a
|
||||
hero comes back.
|
||||
|
||||
NOT the `MAX_EXCLUDE` clamp, which this sends three keys against a cap of 32 and cannot see. The
|
||||
docstring used to claim it did (round 2). The clamp is deliberately left unpinned: it guards
|
||||
nothing the route would otherwise do — see the constant's own comment — and a test that sent 33
|
||||
keys could only assert that the request still answers, which the comma split already proves."""
|
||||
user, _ = _seed_yt(db)
|
||||
db.add(
|
||||
VideoState(
|
||||
user_id=user.id,
|
||||
video_id="vmed",
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=NOW,
|
||||
)
|
||||
)
|
||||
db.add(ComfortPin(user_id=user.id, video_id="vlong", position=0))
|
||||
db.commit()
|
||||
c = as_user(user)
|
||||
|
||||
# `start` rotates which tier answers first: 0 leads with resume, 1 with the shelf.
|
||||
assert c.get("/api/discover/pick?seed=1&start=0").json()["hero"]["tier"] == "resume"
|
||||
assert c.get("/api/discover/pick?seed=1&start=1").json()["hero"]["tier"] == "comfort"
|
||||
|
||||
skip = ",".join(f"youtube:{v}" for v in ("vshort", "vmed", "vlong"))
|
||||
assert c.get(f"/api/discover/pick?seed=2&exclude={skip}").json()["hero"] is None
|
||||
assert c.get("/api/discover/pick?budget=whenever").status_code == 422
|
||||
|
||||
|
||||
def test_pinning_something_that_does_not_exist_is_a_404(db, as_user):
|
||||
user, _ = _seed_yt(db)
|
||||
r = as_user(user).post("/api/discover/pins", json={"kind": "video", "ref": "nope"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_a_pin_whose_target_vanishes_mid_request_is_a_404_not_a_500(db, as_user, monkeypatch):
|
||||
"""`add_pin`'s `except IntegrityError` arm was reached by no test at all, so the 500→404
|
||||
narrowing could be reverted with the whole suite green (round 2). The 404 above comes from the
|
||||
`resolve_ref` PRE-CHECK, a different branch that returns before the INSERT.
|
||||
|
||||
The race the arm exists for — a Plex sync dropping the row between `resolve_ref` confirming it
|
||||
and the INSERT — needs two transactions, but the BRANCH does not: a `resolve_ref` pointing at an
|
||||
id no row carries lets the pre-check pass and trips the same FK on commit, with the re-query
|
||||
finding no winner. The sentinel has to FIT `comfort_pins.video_id` (varchar(16)) — a longer one
|
||||
raises `DataError` on the way in, which this arm does not catch and never meant to. Its sibling arm (a concurrent duplicate, where the re-query DOES find a winner
|
||||
and answers 200) genuinely needs a second connection and stays uncovered."""
|
||||
user, _ = _seed_yt(db)
|
||||
monkeypatch.setattr(pins, "resolve_ref", lambda *a, **k: ("video_id", "gone-mid-req"))
|
||||
r = as_user(user).post("/api/discover/pins", json={"kind": "video", "ref": "vshort"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_another_users_playlist_is_not_pinnable(db, as_user):
|
||||
"""404, not 403 — a 403 would confirm the id exists to a stranger."""
|
||||
user, _ = _seed_yt(db)
|
||||
other = User(email="other-e24@example.com")
|
||||
db.add(other)
|
||||
db.flush()
|
||||
pl = Playlist(user_id=other.id, name="Theirs")
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
r = as_user(user).post("/api/discover/pins", json={"kind": "playlist", "ref": str(pl.id)})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_reorder_ignores_ids_that_are_not_yours(db, as_user):
|
||||
"""A stale tab replaying an order that includes a since-deleted pin should reorder what still
|
||||
exists rather than fail wholesale."""
|
||||
user, ch = _seed_yt(db)
|
||||
c = as_user(user)
|
||||
a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
|
||||
b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
|
||||
assert c.post("/api/discover/pins/reorder", json={"ids": [b, 9999, a]}).status_code == 200
|
||||
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, a]
|
||||
|
||||
|
||||
def test_deleting_a_pin_reports_what_went(db, as_user):
|
||||
user, _ = _seed_yt(db)
|
||||
c = as_user(user)
|
||||
pin_id = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
|
||||
r = c.delete(f"/api/discover/pins/{pin_id}")
|
||||
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
|
||||
assert c.get("/api/discover/pins").json()["items"] == []
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
||||
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads.errors import classify
|
||||
|
||||
|
||||
@@ -54,3 +55,22 @@ def test_login_beats_generic_unavailable_when_both_present():
|
||||
"This video is unavailable"
|
||||
)
|
||||
assert classify(msg) == "login_required"
|
||||
|
||||
|
||||
def test_our_own_worker_refusals_classify_as_themselves():
|
||||
# The worker raises these itself (not yt-dlp), and the rule that catches each one matches only a
|
||||
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
||||
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
||||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||||
|
||||
|
||||
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
|
||||
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
|
||||
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
|
||||
raw = "ERROR: Unable to download webpage: <urlopen error [Errno 111] Connection refused>"
|
||||
assert dlerrors.user_error_text("url", raw, "network") == dlerrors.NETWORK_REDACTED_MESSAGE
|
||||
# Same failure, but the destination was never user-chosen — nothing to leak, keep the detail.
|
||||
assert dlerrors.user_error_text("youtube", raw, "network") == raw
|
||||
# A user-chosen host, but a category that says something real about the CONTENT, not the network.
|
||||
login = "ERROR: [facebook] 123: You must be logged in to see this post"
|
||||
assert dlerrors.user_error_text("url", login, "login_required") == login
|
||||
|
||||
@@ -10,6 +10,8 @@ Uses the `db` fixture (real Postgres) — skipped when no database is reachable.
|
||||
its OWN session, so every test COMMITS its setup before calling it.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, quota
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _claim_job
|
||||
|
||||
@@ -169,3 +171,110 @@ def test_list_downloads_is_capped(db, monkeypatch):
|
||||
monkeypatch.setattr(dl, "_MAX_LIST", 2)
|
||||
rows = dl.list_downloads(user=u, db=db)
|
||||
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
|
||||
|
||||
|
||||
# --- E12 · S3 · the worker-side max_bytes remainder ------------------------------------------
|
||||
|
||||
|
||||
def _sized_asset(db, size_bytes, ref):
|
||||
a = MediaAsset(
|
||||
source_kind="url",
|
||||
source_ref=ref,
|
||||
format_sig="s",
|
||||
status="ready",
|
||||
ref_count=1,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
return a
|
||||
|
||||
|
||||
def test_remaining_bytes_is_none_for_an_unlimited_user(db):
|
||||
u = User(email="unlimited@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
_quota(db, u.id, max_concurrent=2, unlimited=True)
|
||||
db.flush()
|
||||
# None, not a number: "no cap" and "no budget left" must never be the same value downstream —
|
||||
# build_ydl_opts leaves max_filesize unset for both, so only the caller can tell them apart.
|
||||
assert quota.remaining_bytes(db, u.id) is None
|
||||
|
||||
|
||||
def test_remaining_bytes_is_the_unspent_part_of_the_cap(db):
|
||||
u = User(email="partial@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 400, "partial-1")
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 600
|
||||
|
||||
|
||||
def test_remaining_bytes_is_zero_once_the_cap_is_met(db):
|
||||
# THE case this exists for: `check_enqueue` let these jobs in while the user was under the cap,
|
||||
# and an earlier one has filled it since. Zero is what makes the worker refuse; if this ever
|
||||
# returned the raw negative or None instead, the user already over quota would be the only one
|
||||
# downloading with no size ceiling at all.
|
||||
u = User(email="atcap@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 1500, "atcap-1") # overshot: one job finished larger than the remainder
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 0
|
||||
|
||||
|
||||
def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch, tmp_path):
|
||||
"""A user at their cap fails their own job, and another user's queued job for the same video
|
||||
still downloads.
|
||||
|
||||
MediaAssets are deduped across users, and the generic failure handler writes the message onto
|
||||
that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which
|
||||
`worker._process_job` would then hand verbatim to someone with an empty library.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
over, other = User(email="over@example.com"), User(email="other@example.com")
|
||||
db.add_all([over, other])
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
user_id=over.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False
|
||||
)
|
||||
)
|
||||
full = _sized_asset(db, 1500, "already-held")
|
||||
_job(db, over.id, queue_pos=0, status="done", asset_id=full.id)
|
||||
|
||||
# `downloading` is the state the worker leaves it in — `_claim_asset` compare-and-sets it before
|
||||
# the child runs — and `_reset_asset_pending` only acts on that status. Seeded as `pending` this
|
||||
# assertion would pass with the release deleted.
|
||||
shared = MediaAsset(
|
||||
source_kind="youtube",
|
||||
source_ref="x" * 11,
|
||||
format_sig="s",
|
||||
status="downloading",
|
||||
ref_count=2,
|
||||
)
|
||||
db.add(shared)
|
||||
db.flush()
|
||||
mine = _job(db, over.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||
theirs = _job(db, other.id, queue_pos=2, asset_id=shared.id)
|
||||
db.commit()
|
||||
|
||||
# The refusal happens before extraction, so nothing needs stubbing — if it ever stops happening
|
||||
# first, this test starts reaching the network, which is its own signal.
|
||||
pipeline._download(mine.id, shared.id, "youtube", "x" * 11, {}, over.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, mine.id).status == "error"
|
||||
assert db.get(DownloadJob, mine.id).error_code == "quota_exceeded"
|
||||
# The asset is released, NOT marked error — otherwise the next line's job inherits the refusal.
|
||||
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||
assert db.get(MediaAsset, shared.id).error is None
|
||||
assert db.get(DownloadJob, theirs.id).status == "queued"
|
||||
|
||||
@@ -345,3 +345,28 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
|
||||
|
||||
|
||||
# --- DX-17 · the setup wizard resolves its admin canonically ---------------------------------
|
||||
|
||||
|
||||
def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db):
|
||||
"""The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one
|
||||
person. Resolving by the literal string here would create a SECOND admin row — exactly the
|
||||
duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17).
|
||||
|
||||
Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under
|
||||
test, and the test fixtures present an already-configured instance."""
|
||||
from app.routes.setup import SetupAdminIn, setup_admin
|
||||
|
||||
setup_admin(SetupAdminIn(email="a.b.c@gmail.com", password=_UAT_PW), db)
|
||||
setup_admin(SetupAdminIn(email="abc@gmail.com", password="another good passphrase"), db)
|
||||
|
||||
admins = db.execute(select(User).where(User.role == "admin")).scalars().all()
|
||||
assert len(admins) == 1, [u.email for u in admins]
|
||||
# The address stays AS TYPED on the row it matched; only identity is canonical.
|
||||
assert admins[0].email == "a.b.c@gmail.com"
|
||||
# And the second run's password is the live one — it updated, it did not fork.
|
||||
from app.security import verify_password
|
||||
|
||||
assert verify_password("another good passphrase", admins[0].password_hash)
|
||||
|
||||
@@ -109,3 +109,52 @@ def test_browser_transcode_flags_universal_playability():
|
||||
assert F("h264", "aac", False) == (True, False, False)
|
||||
# A failed probe / missing stream is never re-encoded.
|
||||
assert F(None, None, True) == (False, False, False)
|
||||
|
||||
|
||||
# --- SEC-13 · one enqueue is one bounded download -------------------------------------------
|
||||
|
||||
|
||||
def test_a_playlist_url_can_only_yield_one_item():
|
||||
# `noplaylist` alone only demotes a video-inside-a-playlist URL to that video; a URL that is
|
||||
# purely a playlist or channel has no such video, so without this it expands into every entry.
|
||||
opts = _opts({})
|
||||
assert opts["noplaylist"] is True
|
||||
assert opts["playlist_items"] == "1"
|
||||
|
||||
|
||||
def test_the_remaining_quota_becomes_a_hard_size_ceiling():
|
||||
opts = formats.build_ydl_opts({}, "/tmp/x", lambda d: None, max_bytes=5_000_000)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_no_budget_means_no_size_filter():
|
||||
# Unlimited users pass None; a non-positive remainder is the quota layer's refusal to make at
|
||||
# enqueue, not an impossible yt-dlp filter to express here.
|
||||
for budget in (None, 0, -1):
|
||||
assert "max_filesize" not in formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, max_bytes=budget
|
||||
)
|
||||
|
||||
|
||||
def test_a_source_adapter_cannot_raise_the_size_ceiling():
|
||||
# The adapter merge happens after max_filesize is set, so this pins that no adapter option can
|
||||
# widen a storage cap — the mirror of the cookiefile rule, in the opposite direction.
|
||||
opts = formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, extra={"max_filesize": 999_999_999}, max_bytes=5_000_000
|
||||
)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_a_join_cut_list_is_capped():
|
||||
# An accurate join emits two filters per segment into one ffmpeg filter_complex, so an unbounded
|
||||
# list is an unbounded command line. The surplus is dropped; the legitimate head survives.
|
||||
segs = [{"start_s": i, "end_s": i + 1} for i in range(500)]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == editmod._MAX_SEGMENTS
|
||||
assert out["segments"][0] == {"start_s": 0.0, "end_s": 1.0}
|
||||
|
||||
|
||||
def test_a_normal_cut_list_is_untouched():
|
||||
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == 2
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""E10 S2 — the one-step "move this video to another list" endpoint, and the `updated_at` touch
|
||||
the rail's "recently changed" sort reads.
|
||||
|
||||
Two properties are load-bearing and neither is visible from the happy path alone:
|
||||
|
||||
1. **The move is one transaction.** The client-side shape it replaces (add to the target, then
|
||||
remove from the source) leaves the video in BOTH lists when the second call fails. So the
|
||||
tests assert the *pair* of lists after every call, never just the destination.
|
||||
2. **`updated_at` tracks item edits.** `UpdatedAtMixin.onupdate` only fires on an UPDATE of the
|
||||
playlist row, and add/remove/reorder/move write `playlist_items` — so the timestamp is bumped
|
||||
explicitly, and a regression there would silently degrade the sort to "renames only".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||
|
||||
|
||||
def _seed_videos(db, ids):
|
||||
db.add(Channel(id="UCtest", title="Test"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in ids:
|
||||
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _playlist(db, user, name, video_ids, **kw):
|
||||
pl = Playlist(user_id=user.id, name=name, **kw)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for i, vid in enumerate(video_ids):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
def _ids(db, pl_id):
|
||||
return [
|
||||
it.video_id
|
||||
for it in db.execute(
|
||||
PlaylistItem.__table__.select()
|
||||
.where(PlaylistItem.playlist_id == pl_id)
|
||||
.order_by(PlaylistItem.position)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _move(client, src, video_id, dst):
|
||||
return client.post(
|
||||
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
|
||||
)
|
||||
|
||||
|
||||
def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2"])
|
||||
src = _playlist(db, user, "Source", ["v1", "v2"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", dst.id)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"from_playlist_id": src.id,
|
||||
"to_playlist_id": dst.id,
|
||||
"video_id": "v1",
|
||||
}
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v2"]
|
||||
assert _ids(db, dst.id) == ["v1"]
|
||||
|
||||
|
||||
def test_move_appends_to_the_end_of_the_target(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2", "v3"])
|
||||
src = _playlist(db, user, "Source", ["v3"])
|
||||
dst = _playlist(db, user, "Target", ["v1", "v2"])
|
||||
|
||||
assert _move(as_user(user), src.id, "v3", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, dst.id) == ["v1", "v2", "v3"]
|
||||
|
||||
|
||||
def test_move_into_a_list_that_already_has_it_is_a_removal_not_a_duplicate(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
dst = _playlist(db, user, "Target", ["v1"])
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == []
|
||||
assert _ids(db, dst.id) == ["v1"] # exactly one row, not two
|
||||
|
||||
|
||||
def test_move_to_the_same_list_is_refused(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", src.id)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v1"]
|
||||
|
||||
|
||||
def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", [])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", dst.id)
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
|
||||
|
||||
|
||||
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
|
||||
user, other = make_user(), make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Mine", ["v1"])
|
||||
theirs = _playlist(db, other, "Theirs", [])
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", theirs.id).status_code == 404
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v1"]
|
||||
assert _ids(db, theirs.id) == []
|
||||
|
||||
|
||||
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
|
||||
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
|
||||
Marking only the source is the asymmetric fix this repo keeps re-learning."""
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Src", ["v1"], yt_playlist_id="PLsrc", synced_video_ids='["v1"]')
|
||||
dst = _playlist(db, user, "Dst", [], yt_playlist_id="PLdst", synced_video_ids="[]")
|
||||
# Baseline: both are in sync with YouTube before the move.
|
||||
src.dirty = False
|
||||
dst.dirty = False
|
||||
db.commit()
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).dirty is True
|
||||
assert db.get(Playlist, dst.id).dirty is True
|
||||
|
||||
|
||||
def test_item_edits_bump_updated_at(db, make_user, as_user):
|
||||
"""The rail's "recently changed first" sort reads this. Adding, moving and reordering all
|
||||
write `playlist_items` only, so nothing here is covered by the ORM's `onupdate`."""
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2"])
|
||||
src = _playlist(db, user, "Source", ["v1", "v2"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
old = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
src.updated_at = old
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
assert client.post(f"/api/playlists/{dst.id}/items", json={"video_id": "v1"}).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, dst.id).updated_at > old
|
||||
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
assert (
|
||||
client.put(f"/api/playlists/{src.id}/order", json={"video_ids": ["v2", "v1"]}).status_code
|
||||
== 200
|
||||
)
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
|
||||
src.updated_at = old
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
assert _move(client, src.id, "v2", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
# BOTH sides of a move are "recently changed" — the target is where the reader will look.
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
assert db.get(Playlist, dst.id).updated_at > old
|
||||
|
||||
|
||||
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_playlist(db, user, "Solo", [])
|
||||
rows = as_user(user).get("/api/playlists").json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
|
||||
@@ -12,7 +12,7 @@ import app.plex.sync as syncmod
|
||||
from app import sysconfig
|
||||
from app.models import PlexItem, PlexLibrary
|
||||
from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
|
||||
from app.routes.plex._shared import _content_libs
|
||||
from app.plex.query import content_libs
|
||||
|
||||
|
||||
class _StubPlex:
|
||||
@@ -155,7 +155,7 @@ def test_sync_designates_overlay_and_writes_hu(db, monkeypatch):
|
||||
assert m.title_hu == "Öldöklés"
|
||||
assert m.genres_hu == ["Akció"]
|
||||
# The HU section is not browsable and mirrored no rows of its own.
|
||||
movie_ids, _ = _content_libs(db)
|
||||
movie_ids, _ = content_libs(db)
|
||||
assert en.id in movie_ids and hu.id not in movie_ids
|
||||
assert db.scalar(select(func.count(PlexItem.id)).where(PlexItem.library_id == hu.id)) == 0
|
||||
assert stats["overlaid"] == 1
|
||||
@@ -240,7 +240,7 @@ def test_content_libs_excludes_overlay_libraries(db):
|
||||
db.add(overlay)
|
||||
db.commit()
|
||||
|
||||
movie_ids, show_ids = _content_libs(db)
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
assert en_lib.id in movie_ids
|
||||
assert overlay.id not in movie_ids # the HU overlay is never browsable
|
||||
assert show_ids == [en_shows.id]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""DB-backed regression (E5 S4): GET /api/plex/person/{name} returns a cast/crew member's
|
||||
filmography — movies + shows in the ENABLED libraries whose cast OR directors include the name,
|
||||
each carrying the caller's watch-state, plus the cached headshot. Also covers `_content_libs`
|
||||
each carrying the caller's watch-state, plus the cached headshot. Also covers `content_libs`
|
||||
scoping (disabled libraries excluded) and the kind filter (episodes are not movies).
|
||||
|
||||
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
@@ -20,7 +20,7 @@ from app.models import (
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.routes.plex._shared import _content_libs
|
||||
from app.plex.query import content_libs
|
||||
|
||||
NAME = "Robert De Niro"
|
||||
|
||||
@@ -151,5 +151,5 @@ def test_content_libs_partitions_only_enabled(db):
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
movie_ids, show_ids = _content_libs(db)
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""E12 · S5 — the admin-issued set-password link.
|
||||
|
||||
One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so
|
||||
a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until
|
||||
its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs".
|
||||
|
||||
The property that must not regress is the one that makes it honest: an admin-issued link proves
|
||||
NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not
|
||||
confirm the address the way a mailed reset does.
|
||||
"""
|
||||
|
||||
from app.models import AuthToken
|
||||
|
||||
|
||||
def _link(client_admin, user_id: int):
|
||||
r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["url"].split("#reset=", 1)[1]
|
||||
|
||||
|
||||
def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client):
|
||||
"""The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing
|
||||
it proves ownership and confirms the address. An admin-issued link does not, and claiming
|
||||
otherwise would let an admin confirm an address nobody ever proved."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False)
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash is not None
|
||||
assert target.email_verified is False # the address is still unproven — and says so
|
||||
|
||||
|
||||
def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client):
|
||||
"""The mutation check for the test above: if `email_verified` were simply never set, this would
|
||||
fail — and the rescue path for "registered but never clicked the link" would be gone."""
|
||||
from datetime import timedelta
|
||||
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(email_verified=False)
|
||||
db.commit()
|
||||
token = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(user)
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client):
|
||||
"""SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password.
|
||||
The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather
|
||||
than writing a second one that could forget to bump."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
before = target.session_epoch
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
token = _link(as_user(admin), target.id)
|
||||
client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
|
||||
)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.session_epoch != before
|
||||
|
||||
|
||||
def test_issuing_does_not_touch_the_current_password(db, as_user, make_user):
|
||||
"""An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes
|
||||
anything — which is also why it is safe to offer on a perfectly healthy account."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
original = target.password_hash
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
_link(as_user(admin), target.id)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash == original
|
||||
assert target.password_revoked_at is None
|
||||
|
||||
|
||||
def test_the_link_is_single_use(db, as_user, make_user, client):
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
first = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"}
|
||||
)
|
||||
second = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 400
|
||||
|
||||
|
||||
def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client):
|
||||
"""Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset
|
||||
would leave a live admin link behind — a second key to a door someone just changed the lock on."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
first = _link(as_user(admin), target.id)
|
||||
second = _link(as_user(admin), target.id)
|
||||
|
||||
ok = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
stale = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"}
|
||||
)
|
||||
assert stale.status_code == 400
|
||||
|
||||
|
||||
def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client):
|
||||
"""`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked
|
||||
only at `reset` tokens, so without teaching it the new kind every admin-issued link would open
|
||||
onto "this link is spent"."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post("/auth/password-reset/check", json={"token": token})
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_only_an_admin_can_mint_one(db, as_user, make_user):
|
||||
plain = make_user()
|
||||
victim = make_user()
|
||||
db.commit()
|
||||
|
||||
r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link")
|
||||
assert r.status_code in (401, 403)
|
||||
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
|
||||
|
||||
|
||||
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
|
||||
"""The demo account is shared, so handing anyone a way to change its password hands them
|
||||
everyone else's session on it."""
|
||||
admin = make_user(role="admin")
|
||||
demo = make_user(is_demo=True)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "demo"
|
||||
|
||||
|
||||
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False, is_active=False)
|
||||
db.commit()
|
||||
|
||||
body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json()
|
||||
assert body["email_verified"] is False
|
||||
assert body["is_active"] is False
|
||||
assert set(body) >= {
|
||||
"is_allowed",
|
||||
"can_activate",
|
||||
"email_configured",
|
||||
"password_revoked_at",
|
||||
"google_email",
|
||||
"has_password",
|
||||
"has_google",
|
||||
}
|
||||
|
||||
|
||||
def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user):
|
||||
""" "Connected" and "connected as someone else" are different statements, and only the second one
|
||||
explains a confusing sign-in. Repeating the account's own address would be noise on every row."""
|
||||
admin = make_user(role="admin")
|
||||
same = make_user(email="same@example.com")
|
||||
same.google_email = "same@example.com"
|
||||
other = make_user(email="typed@example.com")
|
||||
other.google_email = "google-primary@example.com"
|
||||
db.commit()
|
||||
|
||||
c = as_user(admin)
|
||||
assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None
|
||||
assert (
|
||||
c.get(f"/api/admin/users/{other.id}/access").json()["google_email"]
|
||||
== "google-primary@example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user):
|
||||
"""The audit log is read by humans and kept long; a live credential-setting link is the last
|
||||
thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact."""
|
||||
from app.models import AuditLog
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"]
|
||||
secret = url.split("#reset=", 1)[1]
|
||||
|
||||
rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].summary == target.email
|
||||
assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after)
|
||||
|
||||
|
||||
def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client):
|
||||
"""Deliberately not gated on the account's state: the admin's real case is whichever one it is,
|
||||
and narrowing this would only make it refuse exactly then."""
|
||||
admin = make_user(role="admin")
|
||||
google_only = make_user(password=None)
|
||||
db.commit()
|
||||
assert google_only.password_hash is None
|
||||
|
||||
token = _link(as_user(admin), google_only.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
db.refresh(google_only)
|
||||
assert google_only.password_hash is not None
|
||||
|
||||
|
||||
def test_an_unknown_user_is_a_404(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404
|
||||
assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404
|
||||
|
||||
|
||||
def test_the_issued_token_is_stored_hashed(db, as_user, make_user):
|
||||
"""Same rule as every other token here: the DB holds a hash, so a database read cannot hand
|
||||
someone a working link."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
raw = _link(as_user(admin), target.id)
|
||||
rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].token_hash != raw
|
||||
assert raw not in rows[0].token_hash
|
||||
|
||||
|
||||
def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user):
|
||||
"""It travels out of band — the admin has to actually reach the person — so the mailed reset's
|
||||
one hour is far too short."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
_link(as_user(admin), target.id)
|
||||
row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one()
|
||||
assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2)
|
||||
|
||||
|
||||
def test_users_listing_still_serializes(db, as_user, make_user):
|
||||
"""The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel
|
||||
endpoint exists to keep out of it."""
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
rows = as_user(admin).get("/api/admin/users").json()
|
||||
assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0]
|
||||
assert "can_activate" not in rows[0]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""SEC-12 · the download URL guard.
|
||||
|
||||
The Download Center hands a user-supplied URL to yt-dlp on the worker, so the server fetches
|
||||
whatever the user typed. These pin that a URL resolving into our own network is refused, that the
|
||||
refusal reasons stay distinct (an unresolvable host is not the same message as a blocked one), and
|
||||
that a normal public URL still goes through.
|
||||
|
||||
DNS is stubbed rather than live: the point under test is the address JUDGEMENT, and a test that
|
||||
resolves real names would be slow, offline-fragile, and would silently start passing if the guard
|
||||
stopped resolving at all.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def _stub_dns(monkeypatch, addrs):
|
||||
"""Make every lookup answer with `addrs` (a list of textual IPs)."""
|
||||
|
||||
def fake_getaddrinfo(host, port, *a, **kw):
|
||||
return [(None, None, None, "", (addr, port or 0)) for addr in addrs]
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
|
||||
def _blocked_reason(url):
|
||||
with pytest.raises(urlguard.BlockedUrl) as e:
|
||||
urlguard.check_url(url)
|
||||
return e.value.reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"addr",
|
||||
[
|
||||
"127.0.0.1", # loopback
|
||||
"10.1.2.3", # RFC1918
|
||||
"192.168.0.5", # RFC1918
|
||||
"172.16.9.9", # RFC1918
|
||||
"169.254.169.254", # link-local — the cloud metadata endpoint
|
||||
"0.0.0.0", # unspecified
|
||||
"::1", # IPv6 loopback
|
||||
"fe80::1", # IPv6 link-local
|
||||
"fc00::1", # IPv6 unique-local
|
||||
"::ffff:127.0.0.1", # loopback wearing an IPv6 hat
|
||||
],
|
||||
)
|
||||
def test_addresses_on_our_own_network_are_refused(monkeypatch, addr):
|
||||
_stub_dns(monkeypatch, [addr])
|
||||
assert _blocked_reason("http://whatever.example/video") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_one_bad_answer_in_a_round_robin_blocks_the_whole_host(monkeypatch):
|
||||
# A host answering with both a public and an internal address must not become a coin flip:
|
||||
# yt-dlp would pick for itself, so the guard refuses if ANY answer is forbidden.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34", "127.0.0.1"])
|
||||
assert _blocked_reason("https://mixed.example/v") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_a_public_address_passes(monkeypatch):
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
urlguard.check_url("https://example.com/watch?v=abc") # does not raise
|
||||
|
||||
|
||||
def test_unresolvable_is_its_own_reason(monkeypatch):
|
||||
import socket as _socket
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise _socket.gaierror("nope")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
# Distinct from PRIVATE on purpose: "we couldn't look that up" and "we refuse to fetch that"
|
||||
# are different things to tell a user, and the frontend renders them differently.
|
||||
assert _blocked_reason("https://nx.example/v") == urlguard.REASON_UNRESOLVABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "gopher://host/1"])
|
||||
def test_non_http_schemes_are_refused_before_any_lookup(monkeypatch, url):
|
||||
def boom(*a, **kw): # a scheme rejection must not have resolved anything first
|
||||
raise AssertionError("DNS was consulted for a non-http scheme")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
assert _blocked_reason(url) == urlguard.REASON_SCHEME
|
||||
|
||||
|
||||
def test_a_url_without_a_host_is_refused():
|
||||
assert _blocked_reason("http:///just/a/path") == urlguard.REASON_NO_HOST
|
||||
|
||||
|
||||
def test_a_malformed_url_is_a_refusal_not_a_crash(monkeypatch):
|
||||
# Both shapes raise ValueError LATE — `urlparse` on an unclosed bracket, `.port` only when the
|
||||
# property is READ — and `.hostname` happily returns first. Unguarded, either turns a user typo
|
||||
# into a 500 that escapes the caller's `except BlockedUrl` and loses the localized detail this
|
||||
# module exists to produce.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
assert _blocked_reason("http://example.com:99999/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://example.com:abc/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[::1/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[example.com]/v.mp4") == urlguard.REASON_MALFORMED
|
||||
@@ -53,6 +53,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -74,6 +79,9 @@ services:
|
||||
worker:
|
||||
image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -93,6 +101,8 @@ services:
|
||||
networks: [internal]
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -114,6 +124,8 @@ services:
|
||||
volumes:
|
||||
siftlode_pgdata:
|
||||
siftlode_downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
siftlode_plex_media:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
@@ -38,6 +38,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
||||
interval: 15s
|
||||
@@ -57,6 +62,9 @@ services:
|
||||
GIT_SHA: ${GIT_SHA:-unknown}
|
||||
BUILD_DATE: ${BUILD_DATE:-}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
@@ -73,6 +81,8 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
|
||||
@@ -85,3 +95,5 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
plex_media:
|
||||
|
||||
+188
-7
@@ -115,13 +115,34 @@ DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
|
||||
The directory must be writable by the container user (uid `1000`): `sudo chown -R 1000:1000 <dir>`.
|
||||
|
||||
## Plex library (optional)
|
||||
|
||||
If you run a **Plex** server, Siftlode can browse and play your movies and shows beside your YouTube
|
||||
feed. Two things have to line up:
|
||||
|
||||
1. **Server URL + token**, entered as admin under **Configuration → Plex**. That is how Siftlode reads
|
||||
your libraries.
|
||||
2. **The media files themselves**, mounted into the container read-only. Siftlode streams the files
|
||||
directly rather than proxying Plex, so it has to be able to open them.
|
||||
|
||||
Point `PLEX_MEDIA_HOST_PATH` at the directory holding your library and re-run `up -d`:
|
||||
|
||||
```bash
|
||||
PLEX_MEDIA_HOST_PATH=/mnt/media
|
||||
```
|
||||
|
||||
It appears inside the container at `/plex-media`, read-only. Siftlode then rewrites the paths Plex
|
||||
reports into that mount using the **path map** on the same admin page — its default, `/data=/plex-media`,
|
||||
means "wherever Plex says `/data/movies/…`, look in `/plex-media/movies/…`". Set the left-hand side to
|
||||
whatever prefix your Plex server uses, and mount the matching host directory so the rest of the path
|
||||
still lines up.
|
||||
|
||||
Leave `PLEX_MEDIA_HOST_PATH` unset if you don't use Plex — the mount falls back to an empty volume and
|
||||
the module stays off.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
# Update to the latest release
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
|
||||
# Logs / status (api = web, worker = downloads/edits)
|
||||
docker compose -f docker-compose.selfhost.yml logs -f api
|
||||
docker compose -f docker-compose.selfhost.yml logs -f worker
|
||||
@@ -131,6 +152,166 @@ docker compose -f docker-compose.selfhost.yml ps
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
```
|
||||
|
||||
Your data (accounts, subscriptions, playlists, the video catalog) lives in the `siftlode_pgdata`
|
||||
Docker volume — back that up to keep your instance's state. Database migrations run automatically
|
||||
when the app starts, so updating is just pull + up.
|
||||
## Backups
|
||||
|
||||
**A backup is three things, not one.** Copying only the database gets you an instance that starts and
|
||||
then cannot read its own secrets.
|
||||
|
||||
1. **The database** — accounts, subscriptions, playlists, the video catalog, and every setting you
|
||||
made in the admin pages. It lives in the `siftlode_pgdata` Docker volume; dump it with the command
|
||||
below.
|
||||
2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex
|
||||
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
|
||||
against a different key and those values are lost — you would have to clear the affected rows from
|
||||
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
|
||||
3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_downloads` volume, or
|
||||
whatever `DOWNLOAD_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
|
||||
metadata are in the database, the files themselves are not.
|
||||
|
||||
Dump and restore the database with these two commands. They also exist as scripts
|
||||
(`scripts/backup.sh` + `scripts/restore.sh`, or `backup.ps1` / `restore.ps1` on Windows) if you have
|
||||
the source checkout:
|
||||
|
||||
If you customised `POSTGRES_USER` / `POSTGRES_DB` in `.env`, substitute them for `siftlode` below —
|
||||
the scripts read them from the environment, these one-liners cannot.
|
||||
|
||||
```bash
|
||||
# Back up. Write to a .part name and rename only on success: redirecting straight into the final
|
||||
# name creates the file BEFORE pg_dump runs, so a failed dump leaves a plausible-looking 0-byte
|
||||
# file in backups/ that you will not discover until the day you need it.
|
||||
mkdir -p backups && out="backups/siftlode-$(date +%Y%m%d-%H%M%S).dump" && \
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_dump -U siftlode -Fc siftlode > "$out.part" \
|
||||
&& [ -s "$out.part" ] && mv "$out.part" "$out" && echo "wrote $out" \
|
||||
|| { rm -f "$out.part"; echo "BACKUP FAILED" >&2; false; }
|
||||
|
||||
# Restore. Stop the app first: pg_restore --clean DROPs each object in its own transaction, and a
|
||||
# running api holds locks that make those DROPs queue — abort the wait and you are left with a
|
||||
# half-dropped database. Restoring with the app down also means nothing writes into a schema that
|
||||
# is only partly back.
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
|
||||
# Start it again only once the restore reported no errors — and, if you are rolling a release back,
|
||||
# only AFTER pinning the older IMAGE_TAG. Every start runs `alembic upgrade head`, so bringing the
|
||||
# newer image back here would re-apply the migrations you just restored away. Use `up -d`, not
|
||||
# `start`: `start` relaunches the existing container, so it ignores the tag you just pinned and
|
||||
# comes back on the image you are rolling back FROM.
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
On Windows, do **not** pipe the dump through PowerShell — it corrupts binary data. Write it inside the
|
||||
container and copy it out instead. Check the exit code between the two steps and use a timestamped
|
||||
name: the dump inside the container keeps its name, so a `cp` that runs after a *failed* dump would
|
||||
copy out the previous run's file and quietly overwrite your only backup with a stale one.
|
||||
|
||||
Paste it as ONE block. `throw` only aborts the statement it is in, so as separate pasted lines the
|
||||
copy would still run after a failed dump — and copy out the previous run's file:
|
||||
|
||||
```powershell
|
||||
& {
|
||||
# Inside the block, because a scriptblock inherits the caller's preference — under a profile that
|
||||
# sets SilentlyContinue, every `throw` below would be swallowed and the block would run on.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$compose = "docker-compose.selfhost.yml"
|
||||
New-Item -ItemType Directory -Force -Path backups | Out-Null
|
||||
$out = "backups\siftlode-$(Get-Date -Format yyyyMMdd-HHmmss).dump"
|
||||
docker compose -f $compose exec -T db pg_dump -U siftlode -Fc siftlode -f /tmp/s.dump
|
||||
$code = $LASTEXITCODE
|
||||
# Remove the in-container dump on the failure path too: leaving it there is how a LATER failed run
|
||||
# copies out this run's file and overwrites a good backup with a stale one.
|
||||
if ($code -ne 0) { docker compose -f $compose exec -T db rm -f /tmp/s.dump; throw "BACKUP FAILED - pg_dump returned $code" }
|
||||
docker compose -f $compose cp db:/tmp/s.dump $out
|
||||
$code = $LASTEXITCODE
|
||||
docker compose -f $compose exec -T db rm -f /tmp/s.dump
|
||||
# Remove the partial file too: `cp` streams, so a mid-stream failure leaves a truncated dump
|
||||
# under the final name — the plausible-looking bad backup the .part dance above avoids.
|
||||
if ($code -ne 0) { Remove-Item $out -ErrorAction SilentlyContinue; throw "BACKUP FAILED - could not copy the dump out (exit $code)" }
|
||||
if ((Get-Item $out).Length -eq 0) { Remove-Item $out; throw "BACKUP FAILED - the dump came out empty" }
|
||||
"wrote $out"
|
||||
}
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
**Take a backup first.** Every start runs any new database migrations automatically, and a migration
|
||||
is not reversible by pulling the old image back — see rollback below.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
That tracks `latest`, which moves whenever a release is cut. **To decide for yourself when you move,
|
||||
pin a version** by putting it in `.env`:
|
||||
|
||||
```bash
|
||||
IMAGE_TAG=0.80.0
|
||||
```
|
||||
|
||||
Then `pull` + `up -d` stays on that version until you change the line. Released versions and what
|
||||
changed in them are listed in the app itself, under **Release Notes** in the account menu, and in
|
||||
[`CHANGELOG.md`](../CHANGELOG.md).
|
||||
|
||||
**The registry keeps only the newest few releases, not every version the changelog lists.** A pin is
|
||||
"decide when you take the next hop", not "stay here forever": once you are several releases behind,
|
||||
your pinned tag can no longer be pulled — which matters the day you rebuild the host, prune your
|
||||
Docker images, or move to a new machine. A container that is already running is unaffected (Docker
|
||||
keeps the image locally), and you can always go back to `latest` or build the image yourself from the
|
||||
repository. If you want to sit on one version long term, keep your own copy:
|
||||
`docker save git.b1fr0st.eu/peter/siftlode:0.80.0 -o siftlode-0.80.0.tar`.
|
||||
|
||||
### Rollback
|
||||
|
||||
If a release misbehaves, set `IMAGE_TAG` back to the previous version and `up -d`. That is enough
|
||||
**as long as the release did not change the database schema**. If it did, the older image will refuse
|
||||
to start against the newer schema.
|
||||
|
||||
**Restoring your pre-upgrade dump is the recommended way out of that** — it puts the database back
|
||||
exactly where the old image expects it, in one step, with no revision arithmetic:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. restore the dump you took before upgrading (see Backups above). Leave the app stopped:
|
||||
# starting it here would run the NEWER image's migrations against the database you just restored.
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
# 3. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
If you have no usable dump, you can step the schema back instead — but **not with `alembic downgrade
|
||||
-1`**. One release routinely ships several migrations (0.80.0 shipped four), so `-1` undoes only the
|
||||
newest one and leaves the schema at a revision the older image *also* cannot resolve: you lose that
|
||||
migration's data and still get a crash loop. Downgrade to the revision the target version expects,
|
||||
which that version can tell you itself:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. ask the version you are rolling back TO which schema revision it expects
|
||||
IMAGE_TAG=0.79.0 docker compose -f docker-compose.selfhost.yml run --rm api alembic heads
|
||||
# 3. step the schema back to exactly that revision — run this with the CURRENT (newer) image,
|
||||
# because it is the one that ships the migrations being undone
|
||||
docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade <revision from step 2>
|
||||
# 4. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
A downgrade drops the columns the newer version added, so anything stored only in those columns is
|
||||
gone. That is the price of not having the dump.
|
||||
|
||||
### A migration failed on start
|
||||
|
||||
The container will keep restarting (`restart: unless-stopped`), and `worker` will sit waiting for an
|
||||
`api` that never becomes healthy. Read the reason first:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml logs api | tail -50
|
||||
```
|
||||
|
||||
Then either fix the cause and let it retry, or roll back: restore your pre-upgrade dump, pin the
|
||||
previous `IMAGE_TAG`, and `up -d`. Do not delete the `siftlode_pgdata` volume to "start clean" unless
|
||||
you have a dump you have actually tested restoring — that volume *is* your instance.
|
||||
|
||||
@@ -67,5 +67,47 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
||||
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
|
||||
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
|
||||
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
|
||||
// the first "just this once" import and the directory silently becomes another components/.
|
||||
files: ["src/components/ui/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
|
||||
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
|
||||
// this rule shipped that way and matched far less than it read like (E10 review round 1).
|
||||
patterns: [
|
||||
{
|
||||
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
|
||||
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
|
||||
regex: String.raw`^\.\./(?!\.\./)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||||
},
|
||||
{
|
||||
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
|
||||
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
|
||||
// because a sibling primitive is the one thing this path may legitimately name.
|
||||
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||||
},
|
||||
{
|
||||
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
|
||||
// component uses, and a `/`-anchored pattern let exactly that one through.
|
||||
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "siftlode-frontend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// The global Shift+W wiring, pinned at its call site.
|
||||
//
|
||||
// What this is: a source contract, the same idiom as uiBoundary.contract.test.ts — a structural rule
|
||||
// asserted directly rather than left to prose. What it is NOT: a behaviour test. The behaviour lives
|
||||
// in history.dom.test.ts and history.burial.dom.test.ts, which drive `navigateOverOverlays` against
|
||||
// real overlay entries; the end-to-end gesture (Shift+W over an open player, then Back) is a browser
|
||||
// check, because jsdom reproduces history ordering only as bookkeeping.
|
||||
//
|
||||
// It exists because the composition line itself was unpinned: App.tsx is the one module no test
|
||||
// renders (main.tsx lazy-imports it and nothing else does), globalKeymap.dom.test.tsx drives the
|
||||
// hook with its OWN handler, and history.dom.test.ts drives the helper with its own navigate — so
|
||||
// dropping `navigateOverOverlays` from App left all 818 tests green while the user-visible bug came
|
||||
// straight back (round 2, verified by running that revert).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const APP = readFileSync(fileURLToPath(new URL("./App.tsx", import.meta.url)), "utf8");
|
||||
|
||||
describe("App · global keymap wiring", () => {
|
||||
it("routes the global home shortcut through navigateOverOverlays", () => {
|
||||
const call = /useGlobalKeymap\(([\s\S]*?)\);/.exec(APP)?.[1];
|
||||
// A rename or a move is a legitimate change — but then this test is the thing that has to be
|
||||
// updated deliberately, which is the whole point of pinning it.
|
||||
expect(
|
||||
call,
|
||||
"App.tsx no longer calls useGlobalKeymap in a shape this test can read",
|
||||
).toBeTypeOf("string");
|
||||
expect(call).toContain("navigateOverOverlays");
|
||||
expect(call).toContain('setPage("home")');
|
||||
});
|
||||
});
|
||||
+25
-4
@@ -29,12 +29,16 @@ import Welcome from "./components/Welcome";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
import BackToTop from "./components/BackToTop";
|
||||
import PageShell from "./components/PageShell";
|
||||
import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import Toaster from "./components/Toaster";
|
||||
import { StateMessage } from "./components/QueryState";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import { navigateOverOverlays } from "./lib/history";
|
||||
import { StateMessage } from "./components/ui/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
@@ -134,6 +138,18 @@ export default function App() {
|
||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
||||
});
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
|
||||
// above the signed-out branch is worse than not binding it.
|
||||
// Through `navigateOverOverlays`, because a global key can fire while an overlay is open and does
|
||||
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile
|
||||
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the
|
||||
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not
|
||||
// decoration: pressed while ALREADY on Home (the default landing page) it navigates nowhere, and
|
||||
// burying the open player's entry under a push that never happened orphaned it — see `buryUnder`.
|
||||
useGlobalKeymap(!!meQuery.data, () => navigateOverOverlays(() => setPage("home")));
|
||||
|
||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
||||
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
||||
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
|
||||
@@ -412,6 +428,11 @@ export default function App() {
|
||||
(collapsed or expanded) without tracking its width. */}
|
||||
<Toaster />
|
||||
</div>
|
||||
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
|
||||
Mounted once, outside the page area, so every surface answers "what can I press here" and
|
||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Info, Sparkles } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
function fmtDate(d?: string | null): string {
|
||||
if (!d) return "—";
|
||||
@@ -70,12 +71,9 @@ export default function About({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" className="mt-4 w-full" onClick={onOpenReleaseNotes}>
|
||||
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
|
||||
</button>
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api } from "../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
//
|
||||
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
|
||||
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
|
||||
// cleared by SEC-10, or which Google account was attached. Every support conversation started by
|
||||
// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks
|
||||
// one having read what is actually wrong.
|
||||
|
||||
/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */
|
||||
function AccessRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "muted",
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "muted" | "warn" | "ok";
|
||||
}) {
|
||||
const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted";
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
|
||||
<span className="text-muted w-28 shrink-0">{label}</span>
|
||||
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPanel({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUserRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: qk.adminUserAccess(user.id),
|
||||
queryFn: () => api.adminUserAccess(user.id),
|
||||
});
|
||||
|
||||
// Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error
|
||||
// dialog on the promise that the caller reports the failure inline. Without an `onError` that
|
||||
// promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this
|
||||
// panel exists for, the admin would read silence as "the mail went out".
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: () => api.activateUser(user.id),
|
||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const copy = async () => {
|
||||
if (!link) return;
|
||||
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||
// minimal self-host this no-email link was built for.
|
||||
if (await copyText(link.url)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
|
||||
// action failing. Say so rather than leaving the button looking inert.
|
||||
notify({ level: "warning", message: t("users.account.copyFailed") });
|
||||
};
|
||||
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={user.display_name ?? user.email.split("@")[0]}
|
||||
onClose={onClose}
|
||||
placement="drawer"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
||||
|
||||
{q.isError && !a ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !a ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.access")}
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
||||
<AccessRow
|
||||
icon={Mail}
|
||||
label={t("users.account.email")}
|
||||
value={
|
||||
a.email_verified
|
||||
? t("users.account.emailConfirmed")
|
||||
: t("users.account.emailUnconfirmed")
|
||||
}
|
||||
tone={a.email_verified ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Clock}
|
||||
label={t("users.account.approval")}
|
||||
value={
|
||||
a.is_active
|
||||
? t("users.account.approvalDone")
|
||||
: a.can_activate
|
||||
? t("users.account.approvalWaitingOnYou")
|
||||
: t("users.account.approvalNotRequested")
|
||||
}
|
||||
tone={a.is_active ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={KeyRound}
|
||||
label={t("users.account.password")}
|
||||
value={
|
||||
a.has_password
|
||||
? t("users.account.passwordSet")
|
||||
: a.password_revoked_at
|
||||
? t("users.account.passwordRevoked")
|
||||
: t("users.account.passwordNone")
|
||||
}
|
||||
// Not having a password is only a PROBLEM when it was taken away; a Google-only
|
||||
// account never had one and is working exactly as intended.
|
||||
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Globe}
|
||||
label={t("users.account.google")}
|
||||
value={
|
||||
!a.has_google
|
||||
? t("users.account.googleNone")
|
||||
: a.google_email
|
||||
? t("users.account.googleAs", { email: a.google_email })
|
||||
: t("users.account.googleConnected")
|
||||
}
|
||||
tone={a.has_google ? "ok" : "muted"}
|
||||
/>
|
||||
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
||||
{!a.is_allowed && (
|
||||
<AccessRow
|
||||
icon={ShieldAlert}
|
||||
label={t("users.account.invite")}
|
||||
value={t("users.account.inviteMissing")}
|
||||
tone="warn"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.recovery")}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<button
|
||||
onClick={() => activate.mutate()}
|
||||
disabled={activate.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => resend.mutate()}
|
||||
disabled={!a.email_configured || a.email_verified || resend.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||
>
|
||||
<Send className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||
whole reason the link below exists, so naming it here is the explanation. */}
|
||||
{!a.email_configured && (
|
||||
<span className="text-muted">{t("users.account.mailOff")}</span>
|
||||
)}
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => issue.mutate()}
|
||||
disabled={issue.isPending || user.is_demo}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||
{link.url}
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||||
|
||||
export interface ActionMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
|
||||
}
|
||||
|
||||
export default function ActionMenu({
|
||||
items,
|
||||
ariaLabel,
|
||||
className,
|
||||
trigger,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
/** Accessible name for the trigger (e.g. "Catalog actions"). */
|
||||
ariaLabel: string;
|
||||
/** Trigger className override; omit for the default glass ⋯ icon button. */
|
||||
className?: string;
|
||||
/** Custom trigger content; omit for the default ⋯ icon. */
|
||||
trigger?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
|
||||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={ariaLabel}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<PopoverItem
|
||||
key={it.id}
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
|
||||
>
|
||||
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||
@@ -172,7 +172,7 @@ export default function ActiveFilterChips({
|
||||
<div
|
||||
ref={laneRef}
|
||||
data-testid="active-filter-chips"
|
||||
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
className={`${chipLaneClass} flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
aria-label={t("feed.chips.label")}
|
||||
>
|
||||
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
||||
|
||||
@@ -24,16 +24,19 @@ import {
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
@@ -107,6 +110,9 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
|
||||
// with per-row disabling the admin can now start actions on several rows concurrently.
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
||||
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
|
||||
// only one is ever mounted.
|
||||
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
|
||||
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
|
||||
const endPending = (id: number) =>
|
||||
setPendingIds((s) => {
|
||||
@@ -192,6 +198,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
|
||||
return (
|
||||
<Section card title={t("users.roles.title")}>
|
||||
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -207,13 +214,17 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{u.display_name ?? u.email.split("@")[0]}
|
||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||
</div>
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
||||
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
||||
@@ -234,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.roles.makeAdmin")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => onToggle(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
|
||||
icon={<Shield className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={
|
||||
@@ -254,36 +267,36 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.suspend.action")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={
|
||||
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
||||
}
|
||||
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
||||
u.is_suspended
|
||||
? "border-warn/40 text-warn hover:bg-warn/10"
|
||||
: "border-border text-muted hover:text-warn"
|
||||
}`}
|
||||
>
|
||||
{u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
icon={
|
||||
u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onDelete(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={t("users.delete.action")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -316,12 +329,9 @@ function PendingAccess() {
|
||||
queryKey: qk.adminPendingAccess(),
|
||||
queryFn: () => api.adminPendingAccess(),
|
||||
});
|
||||
// Activating changes /api/me's badge count too, so refresh both or the number lies until reload.
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() });
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
};
|
||||
// Activating changes /api/me's badge count too, so refresh all of them or the number lies until
|
||||
// reload. Shared with the account panel's copy of this mutation (see lib/adminUsers).
|
||||
const refresh = () => invalidateUserAccess(qc);
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
|
||||
@@ -343,10 +353,9 @@ function PendingAccess() {
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -380,12 +389,6 @@ function PendingAccess() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the resend fail because this instance has no working SMTP? */
|
||||
function isEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "resend" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
|
||||
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
|
||||
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
|
||||
@@ -435,25 +438,28 @@ function PendingAccessRowView({
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={onActivate}
|
||||
disabled={busy}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("users.pendingAccess.activate")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -673,12 +679,14 @@ function AdminInvites() {
|
||||
placeholder={t("settings.invites.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
variant="glass"
|
||||
className="shrink-0"
|
||||
icon={<UserPlus className="w-4 h-4" />}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
||||
</button>
|
||||
{t("settings.invites.add")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{decided.length > 0 && (
|
||||
@@ -820,14 +828,15 @@ function AdminDemo() {
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onRemove(r)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.demo.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -848,12 +857,9 @@ function AdminDemo() {
|
||||
placeholder={t("settings.demo.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
>
|
||||
<Button variant="glass" size="md" className="shrink-0" type="submit">
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
@@ -909,24 +915,26 @@ function InviteRow({
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onApprove}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.approve")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDeny}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deny")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
|
||||
exists for it the row is that account's approval gate, so it is managed from Users & roles
|
||||
@@ -946,14 +954,15 @@ function InviteRow({
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react";
|
||||
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { moduleDef, moduleOrder } from "../lib/modules";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
||||
import { requestSurprise } from "../lib/homeIntent";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
||||
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
||||
//
|
||||
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
|
||||
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
|
||||
// from YouTube". It is a long-running job against a quota, it wants the progress and error surface
|
||||
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
|
||||
// would have saved.
|
||||
//
|
||||
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
|
||||
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
|
||||
export default function AppCommandPalette() {
|
||||
const { t } = useTranslation();
|
||||
const open = usePaletteOpen();
|
||||
const me = useMe();
|
||||
const { setPage, openChannel } = useNavigationActions();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { theme, setTheme } = usePrefs();
|
||||
const savedViews = useSavedViews();
|
||||
|
||||
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
|
||||
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
|
||||
const channelsQ = useQuery({
|
||||
queryKey: qk.channels(),
|
||||
queryFn: () => api.channels(),
|
||||
enabled: open,
|
||||
});
|
||||
const playlistsQ = useQuery({
|
||||
queryKey: qk.playlists(),
|
||||
queryFn: () => api.playlists(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const groups = useMemo<CommandGroup[]>(() => {
|
||||
const out: CommandGroup[] = [];
|
||||
|
||||
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
|
||||
if (me) {
|
||||
const pages = [...moduleOrder(me), "settings" as const];
|
||||
out.push({
|
||||
id: "modules",
|
||||
label: t("palette.groups.modules"),
|
||||
items: pages.map((page) => {
|
||||
const def = moduleDef(page);
|
||||
const Icon = def.icon;
|
||||
return {
|
||||
id: `page:${page}`,
|
||||
label: t(def.labelKey),
|
||||
icon: <Icon className="w-4 h-4" />,
|
||||
// The longer tab title is a second name for the same place ("Channel manager" for
|
||||
// "Channels"), so match on it even though the row shows the short one.
|
||||
keywords: def.titleKey ? t(def.titleKey) : "",
|
||||
run: () => setPage(page),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const channels = channelsQ.data ?? [];
|
||||
if (channels.length > 0) {
|
||||
out.push({
|
||||
id: "channels",
|
||||
label: t("palette.groups.channels"),
|
||||
items: channels
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => ({
|
||||
id: `channel:${c.id}`,
|
||||
label: c.title ?? c.id,
|
||||
hint: c.handle ?? undefined,
|
||||
icon: <Tv className="w-4 h-4" />,
|
||||
keywords: c.handle ?? "",
|
||||
run: () => openChannel(c.id, c.title ?? undefined),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const playlists = playlistsQ.data ?? [];
|
||||
if (playlists.length > 0) {
|
||||
out.push({
|
||||
id: "playlists",
|
||||
label: t("palette.groups.playlists"),
|
||||
items: playlists.map((p) => ({
|
||||
id: `playlist:${p.id}`,
|
||||
label: p.name,
|
||||
hint: t("palette.videoCount", { count: p.item_count }),
|
||||
icon: <ListVideo className="w-4 h-4" />,
|
||||
run: () => {
|
||||
setSelectedId(p.id);
|
||||
setPage("playlists");
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (savedViews.views.length > 0) {
|
||||
out.push({
|
||||
id: "views",
|
||||
label: t("palette.groups.views"),
|
||||
items: savedViews.views.map((v) => ({
|
||||
id: `view:${v.id}`,
|
||||
label: v.name,
|
||||
run: () => savedViews.apply(v),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: "actions",
|
||||
label: t("palette.groups.actions"),
|
||||
items: [
|
||||
// "Meglepetés" — Home's reroll, reachable from anywhere (E24). There is deliberately no
|
||||
// second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
|
||||
// because the palette searches a module's descriptive `titleKey` too and Home's is literally
|
||||
// "Mit nézzek most?". A row that navigates where an existing row already navigates would be
|
||||
// duplication in the one list whose whole job is to be scanned quickly.
|
||||
{
|
||||
id: "action:surprise",
|
||||
label: t("palette.actions.surprise"),
|
||||
icon: <Sparkles className="w-4 h-4" />,
|
||||
keywords: t("home.title"),
|
||||
run: () => {
|
||||
setPage("home");
|
||||
requestSurprise();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action:shortcuts",
|
||||
label: t("keys.title"),
|
||||
icon: <Keyboard className="w-4 h-4" />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
...SCHEMES.map((s) => ({
|
||||
id: `action:theme:${s.id}`,
|
||||
label: t("palette.theme", { name: s.name }),
|
||||
icon: <Palette className="w-4 h-4" />,
|
||||
keywords: s.id,
|
||||
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
return out;
|
||||
}, [
|
||||
t,
|
||||
me,
|
||||
channelsQ.data,
|
||||
playlistsQ.data,
|
||||
savedViews,
|
||||
theme,
|
||||
setTheme,
|
||||
setPage,
|
||||
openChannel,
|
||||
setSelectedId,
|
||||
]);
|
||||
|
||||
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import { Trash2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import DataTable from "./DataTable";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
@@ -72,14 +73,15 @@ export default function AuditLog() {
|
||||
persistKey={LS.auditTable}
|
||||
controlsPosition="top"
|
||||
controlsLeading={
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
disabled={clear.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t("audit.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
emptyText={t("audit.empty")}
|
||||
/>
|
||||
|
||||
@@ -12,15 +12,15 @@ import { useCardPager } from "../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import DataTable, { type Column } from "./ui/DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import Pager from "./ui/Pager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChannelLayout } from "./ChannelsProvider";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Column } from "./DataTable";
|
||||
import VirtualGrid from "./VirtualGrid";
|
||||
import { type Column } from "./ui/DataTable";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||
@@ -24,6 +24,9 @@ interface ChannelLayoutGridProps<T> {
|
||||
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||
actionsKey?: string;
|
||||
rowClassName?: (row: T) => string;
|
||||
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
|
||||
* a channel answers the right-click in the card layout exactly as it does in the table. */
|
||||
rowProps?: (row: T) => Record<string, unknown>;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +38,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
aboutKey,
|
||||
actionsKey,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
emptyText,
|
||||
}: ChannelLayoutGridProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
@@ -50,6 +54,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
|
||||
const renderCard = (row: T) => (
|
||||
<div
|
||||
{...rowProps?.(row)}
|
||||
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -84,7 +89,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
items={rows}
|
||||
getKey={rowKey}
|
||||
minCol={spec.minCol}
|
||||
maxCol={null}
|
||||
colMaxWidth={null}
|
||||
rowEst={spec.rowEst}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
|
||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { channelDetailPollMs } from "../lib/channelDetailPoll";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Tabs from "./Tabs";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||
import { PageToolbar, PageToolbarSlot } from "./ui/PageShell";
|
||||
import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -416,14 +416,14 @@ export default function ChannelPage({
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
{ch?.subscribed && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onShowInFeed}
|
||||
title={t("channel.showInFeed")}
|
||||
aria-label={t("channel.showInFeed")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||||
|
||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
||||
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MyStatus } from "../lib/api";
|
||||
import { formatEta } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
||||
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
@@ -129,7 +129,7 @@ function SuggestedChipsLane({
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1 min-w-0 flex-1">
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -374,7 +374,7 @@ export function ChannelTagsControl({
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
|
||||
@@ -10,26 +10,30 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable from "./DataTable";
|
||||
import Tabs, { type TabDef } from "./Tabs";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { ComfortPinMenu } from "./ComfortPinMenu";
|
||||
import { pinChannel } from "./comfortPin";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./Pager";
|
||||
import Pager from "./ui/Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "./tableHeader";
|
||||
import { filterRows } from "./ui/tableHeader";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -411,6 +415,11 @@ export default function Channels() {
|
||||
/>
|
||||
);
|
||||
const hasLayout = view === "subscribed" || view === "discovery";
|
||||
// The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and
|
||||
// the card layout — they are two paints of the same rows, so a right-click must mean the same
|
||||
// thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a
|
||||
// `Channel` row yet, so pinning one could only 404.
|
||||
const pinMenu = useContextMenu<ManagedChannel>();
|
||||
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
|
||||
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||
const sortControl = (
|
||||
@@ -489,13 +498,15 @@ export default function Channels() {
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 uppercase tracking-wide"
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
icon={<Pencil className="h-3 w-3" />}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
@@ -518,12 +529,9 @@ export default function Channels() {
|
||||
</span>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<button
|
||||
onClick={acceptAllHighConfidence}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -613,6 +621,7 @@ export default function Channels() {
|
||||
onFiltersChange={setFilters}
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
@@ -623,8 +632,14 @@ export default function Channels() {
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
/>
|
||||
)}
|
||||
<ComfortPinMenu
|
||||
menu={pinMenu}
|
||||
target={(c) => pinChannel(c.id)}
|
||||
title={(c) => c.title ?? t("channels.row.thisChannel")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "../lib/channelsUrlState";
|
||||
import { usePersistedTab } from "./Tabs";
|
||||
import type { FilterMap } from "./tableHeader";
|
||||
import { usePersistedTab } from "./ui/Tabs";
|
||||
import type { FilterMap } from "./ui/tableHeader";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "../lib/chatDock";
|
||||
import { onMessage, onUnread } from "../lib/messagesSocket";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
import Overlay from "./Overlay";
|
||||
import Overlay from "./ui/Overlay";
|
||||
|
||||
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
|
||||
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Send } from "lucide-react";
|
||||
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
||||
@@ -213,15 +214,16 @@ export default function ChatThread({
|
||||
placeholder={t("messages.writePlaceholder")}
|
||||
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
|
||||
aria-label={t("messages.send")}
|
||||
title={t("messages.send")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { type PlexCard, type Video } from "../lib/api";
|
||||
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { type ContextMenuController } from "./ui/useContextMenu";
|
||||
|
||||
// The menu SURFACE for the comfort-shelf pin (E24 S3). Split from `comfortPin.tsx` for the same
|
||||
// reason `ContextMenu.tsx` is split from `useContextMenu.ts`: a file that exports components and
|
||||
// nothing else keeps react-refresh working for every surface that imports it.
|
||||
|
||||
/**
|
||||
* The whole right-click menu for a surface whose only context action is the pin — the card grids and
|
||||
* rails. Render ONE per list, outside the rows (see `ui/ContextMenu`), and spread
|
||||
* `menu.triggerProps` on each row.
|
||||
*
|
||||
* `target` maps the row the user right-clicked to its pin; `title` names it in the confirmation
|
||||
* toast. Both come from the call site because only it knows what its rows are.
|
||||
*/
|
||||
export function ComfortPinMenu<T>({
|
||||
menu,
|
||||
target,
|
||||
title,
|
||||
}: {
|
||||
menu: ContextMenuController<T>;
|
||||
target: (row: T) => PinTarget | null;
|
||||
title: (row: T) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pin = useComfortPin(menu.open);
|
||||
const row = menu.target;
|
||||
return (
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("home.pin.menu")}
|
||||
items={row ? pin.item(target(row), title(row)) : []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Plex poster grids and rails — five of them, all pinning the same way. One component so the
|
||||
* show-vs-item decision and the toast's name are written once rather than five times. */
|
||||
export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> }) {
|
||||
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
|
||||
}
|
||||
|
||||
/** The YouTube video lists (the feed grid, the Home rails). */
|
||||
export function VideoPinMenu({ menu }: { menu: ContextMenuController<Video> }) {
|
||||
return <ComfortPinMenu menu={menu} target={(v) => pinVideo(v.id)} title={(v) => v.title ?? ""} />;
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plug, RotateCcw, Send } from "lucide-react";
|
||||
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays, Check, ChevronDown } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import Banner from "./Banner";
|
||||
import Banner from "./ui/Banner";
|
||||
|
||||
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
|
||||
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// Renders the current app error (if any) as a modal the user must acknowledge.
|
||||
|
||||
@@ -38,12 +38,13 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import ItemCount from "./ItemCount";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
@@ -530,20 +531,18 @@ export default function Feed({
|
||||
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
||||
</p>
|
||||
{isDemo ? (
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
{t("feed.browseLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" onClick={onOpenWizard}>
|
||||
{t("feed.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, User } from "lucide-react";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
|
||||
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import SidePanel from "./ui/SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
|
||||
|
||||
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
||||
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Overlay from "./Overlay";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
|
||||
// which is invisible to tsc and to a render-only check:
|
||||
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
|
||||
// • the hero is not printed twice (once as the hero, once as the first resume tile);
|
||||
// • a budget change asks for the new budget and keeps the old answer on screen meanwhile;
|
||||
// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the
|
||||
// identical card straight back and the button looks dead.
|
||||
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
|
||||
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||
import { StrictMode } from "react";
|
||||
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.time ? `${key}:${opts.time}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const discover = vi.fn();
|
||||
const discoverPick = vi.fn();
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: {
|
||||
discover: (...a: unknown[]) => discover(...a),
|
||||
discoverPick: (...a: unknown[]) => discoverPick(...a),
|
||||
setState: () => Promise.resolve(),
|
||||
watchLaterAdd: () => Promise.resolve(),
|
||||
watchLaterRemove: () => Promise.resolve(),
|
||||
},
|
||||
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
|
||||
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||
}));
|
||||
|
||||
// The page hands Plex playback off to the Plex module. The provider itself is not under test, but
|
||||
// WHAT is handed over is: the jump target decides both what plays and where Back goes.
|
||||
const setPage = vi.fn();
|
||||
const setJumpTo = vi.fn();
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }),
|
||||
}));
|
||||
vi.mock("./PlexProvider", () => ({
|
||||
usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }),
|
||||
}));
|
||||
|
||||
import Home from "./Home";
|
||||
import { requestSurprise, takeSurprise } from "../lib/homeIntent";
|
||||
|
||||
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
source: "youtube",
|
||||
kind: "video",
|
||||
id,
|
||||
title,
|
||||
subtitle: "A channel",
|
||||
thumb: null,
|
||||
aspect: "wide",
|
||||
duration_seconds: 600,
|
||||
position_seconds: 120,
|
||||
remaining_seconds: 480,
|
||||
status: "new",
|
||||
plex: null,
|
||||
video: {
|
||||
id,
|
||||
title,
|
||||
channel_id: "UC1",
|
||||
channel_title: "A channel",
|
||||
channel_thumbnail: null,
|
||||
channel_url: "https://youtube.com/channel/UC1",
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
thumbnail_url: null,
|
||||
duration_seconds: 600,
|
||||
view_count: 10,
|
||||
is_short: false,
|
||||
live_status: "none",
|
||||
status: "new",
|
||||
position_seconds: 120,
|
||||
saved: false,
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A Plex SHOW row of the resume rail, carrying the episode that would actually start — what the
|
||||
* backend now attaches (`rails.continue_watching`). */
|
||||
const showCard = () => ({
|
||||
source: "plex",
|
||||
kind: "show",
|
||||
id: "show-1",
|
||||
title: "A Show",
|
||||
subtitle: null,
|
||||
thumb: null,
|
||||
aspect: "poster",
|
||||
duration_seconds: null,
|
||||
position_seconds: 0,
|
||||
remaining_seconds: null,
|
||||
status: "in_progress",
|
||||
video: null,
|
||||
plex: { id: "show-1", type: "show", title: "A Show", thumb: "" },
|
||||
next: {
|
||||
source: "plex",
|
||||
kind: "episode",
|
||||
id: "ep-7",
|
||||
title: "Ep 7",
|
||||
subtitle: "A Show",
|
||||
thumb: null,
|
||||
aspect: "wide",
|
||||
duration_seconds: 1500,
|
||||
position_seconds: 0,
|
||||
remaining_seconds: 1500,
|
||||
status: "new",
|
||||
video: null,
|
||||
plex: { id: "ep-7", type: "episode", title: "Ep 7", thumb: "" },
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (heroId: string, tier = "resume") => ({
|
||||
budget: "any",
|
||||
hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) },
|
||||
continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")],
|
||||
comfort: [],
|
||||
});
|
||||
|
||||
function mount() {
|
||||
return mountWithClient().result;
|
||||
}
|
||||
|
||||
/** Same mount, handing back the QueryClient — the tests that need to drive an invalidation (i.e. a
|
||||
* background refetch, the only way to reach the has-data-and-errored state) cannot reach it
|
||||
* otherwise. */
|
||||
function mountWithClient() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const result = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Home />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { qc, result };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
discover.mockReset();
|
||||
discoverPick.mockReset();
|
||||
setPage.mockReset();
|
||||
setJumpTo.mockReset();
|
||||
discover.mockResolvedValue(payload("v1"));
|
||||
// Module-level state: a token left behind by one test would reroll the next one's first render.
|
||||
takeSurprise();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Home", () => {
|
||||
it("labels the hero from the server's tier, not from the card", async () => {
|
||||
// The same card shape is served under a `surprise` tier: a client that inferred "where you left
|
||||
// off" from the resume position would get this wrong, which is the dishonest state we forbid.
|
||||
discover.mockResolvedValue(payload("v1", "surprise"));
|
||||
mount();
|
||||
expect(await screen.findByText("home.hero.surprise")).toBeTruthy();
|
||||
expect(screen.queryByText(/home\.hero\.resume/)).toBeNull();
|
||||
});
|
||||
|
||||
it("interpolates the remaining time into the resume kicker", async () => {
|
||||
mount();
|
||||
// 480s left → 8:00, passed through the key-passthrough t() as `<key>:<time>`.
|
||||
expect(await screen.findByText("home.hero.resumeLeft:8:00")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an error, never the empty state, when the payload cannot be loaded", async () => {
|
||||
// The one thing this surface must not do: report a backend outage as "nothing to suggest yet"
|
||||
// over a library full of things to suggest.
|
||||
discover.mockRejectedValue(new Error("boom"));
|
||||
mount();
|
||||
expect(await screen.findByText("home.error")).toBeTruthy();
|
||||
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the page on screen when a BACKGROUND refetch fails", async () => {
|
||||
// react-query flips `status` to error on a failed refetch while keeping the cached payload, and
|
||||
// every mutation here invalidates `qk.discover()` — so an unguarded `isError` blanked a fully
|
||||
// populated landing page over one blip.
|
||||
const { qc } = mountWithClient();
|
||||
await screen.findByText("Hero v1");
|
||||
discover.mockRejectedValue(new Error("blip"));
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
// The refetch must actually have been attempted, or this asserts nothing at all.
|
||||
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByText("home.error")).toBeNull();
|
||||
expect(screen.getByText("Hero v1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not print the hero again as the first resume tile", async () => {
|
||||
mount();
|
||||
await screen.findByText("home.hero.resumeLeft:8:00");
|
||||
// "Hero v1" is the hero's own heading; the rail below must not repeat it.
|
||||
expect(screen.getAllByText("Hero v1")).toHaveLength(1);
|
||||
expect(screen.getByText("Second thing")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not print a Plex series twice when the hero is the episode it resolves to", async () => {
|
||||
// The commonest Plex resume case, and the one a single-key match misses: the rail row is the
|
||||
// SHOW while the hero is the episode that show resolves to, so their `source:id` keys differ.
|
||||
// Renamed on `plex` too, not only on the unified card: the rail tile is a `PlexPosterCard`, which
|
||||
// takes its heading from the NATIVE payload — renaming just the outer title made this vacuous.
|
||||
const s = showCard();
|
||||
const show = { ...s, title: "The Series Tile", plex: { ...s.plex, title: "The Series Tile" } };
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "resume", card: show.next },
|
||||
continue_watching: [show, ytCard("v2", "Second thing")],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Second thing");
|
||||
expect(screen.queryByText("The Series Tile")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the resume rail rather than contradicting the hero that emptied it", async () => {
|
||||
// One in-progress item: it becomes the hero, the dedupe filter takes it out of the rail, and the
|
||||
// rail then announced "nothing half-finished right now" under a hero captioned "where you left
|
||||
// off". The shelf's own empty state is untouched — it still has something to teach.
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
|
||||
continue_watching: [ytCard("v1", "Hero v1")],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
expect(screen.queryByText("home.continue.empty")).toBeNull();
|
||||
expect(screen.getByText("home.comfort.empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("holds the hero it last found when a reroll fails", async () => {
|
||||
// A failed pick has no data of its own (every press is a new key, and `keepPreviousData` is
|
||||
// pending-only), so the hero used to rewind to the card the page LOADED with — several presses
|
||||
// back — while `core.req`'s own dialog explained the failure.
|
||||
// No resume rail: "Hero v1" then appears exactly where the hero is, so its reappearance is
|
||||
// unambiguous evidence of a rewind rather than a rail tile that stopped being deduplicated.
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
|
||||
continue_watching: [],
|
||||
comfort: [],
|
||||
});
|
||||
discoverPick.mockResolvedValueOnce({
|
||||
budget: "any",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await screen.findByText("Rerolled");
|
||||
|
||||
// Rejected on OUR schedule: asserting straight after the call goes out catches the query still
|
||||
// pending, where `keepPreviousData` is legitimately holding the old hero — which is not the
|
||||
// state under test and made this pass with or without the fix.
|
||||
let failPick!: (e: Error) => void;
|
||||
discoverPick.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failPick = reject;
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
await act(async () => {
|
||||
failPick(new Error("boom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.getByText("Rerolled")).toBeTruthy();
|
||||
expect(screen.queryByText("Hero v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("never answers a new budget with the previous budget's pick", async () => {
|
||||
// Both halves of the hold outlive the question they answered: the chip row resets seed/seen/press,
|
||||
// but it cannot reset an answer that already RESOLVED, and `keepPreviousData` chains straight
|
||||
// through the disabled seed-0 query the chip click leaves behind. So the first reroll under a new
|
||||
// budget offered the old budget's card — while it loaded, and permanently once it failed, since
|
||||
// `retry: false` makes a failed pick terminal. Under a chip that promises ten minutes, that makes
|
||||
// the page's one control meaningless (round 2).
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
|
||||
continue_watching: [],
|
||||
comfort: [],
|
||||
});
|
||||
discoverPick.mockResolvedValueOnce({
|
||||
budget: "any",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Long film") },
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await screen.findByText("Long film");
|
||||
|
||||
// A different question — whose own answer is already loaded.
|
||||
discover.mockResolvedValue({
|
||||
budget: "short",
|
||||
hero: { tier: "resume", card: ytCard("v2", "Short thing") },
|
||||
continue_watching: [],
|
||||
comfort: [],
|
||||
});
|
||||
fireEvent.click(screen.getByText("home.budget.short"));
|
||||
await screen.findByText("Short thing");
|
||||
|
||||
// Rejected on OUR schedule, as the sibling test above does, so the assertion cannot land while
|
||||
// the query is still pending — that is a different state.
|
||||
let failPick!: (e: Error) => void;
|
||||
discoverPick.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failPick = reject;
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
// In flight: the placeholder must not reach across the budget change.
|
||||
expect(screen.queryByText("Long film")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
failPick(new Error("boom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
// Failed: this budget's own hero, never the film picked for "any".
|
||||
expect(screen.getByText("Short thing")).toBeTruthy();
|
||||
expect(screen.queryByText("Long film")).toBeNull();
|
||||
});
|
||||
|
||||
it("a mutation's invalidation reaches the rerolled hero too", async () => {
|
||||
// The reroll has its own query key so pressing the button does not refetch the rails — but under
|
||||
// the SAME root, or the prefix every mutation invalidates misses it and a rerolled hero outlives
|
||||
// the page: play it, finish it, and it was still the answer, with a zeroed progress bar.
|
||||
discoverPick.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
const { qc } = mountWithClient();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await screen.findByText("Rerolled");
|
||||
expect(discoverPick).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
|
||||
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
|
||||
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
|
||||
// so binding the button's `loading` to raw `isFetching` made it spin and, since `ui/Button` turns
|
||||
// `loading` into `disabled`, swallow clicks after actions the user never associated with a
|
||||
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
|
||||
const rerollBtn = () =>
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
let resolvePick!: (v: unknown) => void;
|
||||
discoverPick.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePick = resolve;
|
||||
}),
|
||||
);
|
||||
const { qc } = mountWithClient();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The positive control, and it is the half most easily broken while fixing the other: this is the
|
||||
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
|
||||
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
|
||||
expect(rerollBtn().disabled).toBe(true);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
resolvePick({ budget: "any", hero: { tier: "surprise", card: ytCard("v9", "Rerolled") } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
await screen.findByText("Rerolled");
|
||||
|
||||
// Never settles, so the assertion lands squarely inside the refetch window rather than after it —
|
||||
// and therefore the invalidation must NOT be awaited: its promise resolves only once every
|
||||
// refetch it started has, which this one never will.
|
||||
discoverPick.mockReturnValue(new Promise(() => {}));
|
||||
act(() => {
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(rerollBtn().disabled).toBe(false);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("a press that FAILED does not leave the button hostage to the next mutation", async () => {
|
||||
// Round 3's correction to the case above. A key whose request failed never gets data of its own
|
||||
// (`retry: false` makes it terminal), so a predicate reading `data === undefined` stayed true
|
||||
// forever and every later background refetch re-disabled the button — the same defect, one
|
||||
// branch over. `isFetched` remembers that the key settled, an error counting as settled.
|
||||
const rerollBtn = () =>
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
let failPick!: (e: Error) => void;
|
||||
discoverPick.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failPick = reject;
|
||||
}),
|
||||
);
|
||||
const { qc } = mountWithClient();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
|
||||
// `waitFor`, not a fixed tick: react-query hands the rejection to the observer through
|
||||
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
|
||||
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
|
||||
failPick(new Error("boom"));
|
||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button
|
||||
|
||||
// Now an unrelated action refetches that errored key in the background.
|
||||
discoverPick.mockReturnValue(new Promise(() => {}));
|
||||
act(() => {
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
expect(rerollBtn().disabled).toBe(false);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the honest empty answer when a press fails after an empty one", async () => {
|
||||
// The tagged slot is authoritative for its own budget, including when what it holds is "no hero
|
||||
// at all". Under a `??` chain that read as "nothing remembered" and fell through to the loaded
|
||||
// payload, so a failed press resurrected the card the page had already excluded as seen and just
|
||||
// said it had nothing beyond (round 3).
|
||||
discover.mockResolvedValue({
|
||||
budget: "short",
|
||||
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
|
||||
continue_watching: [],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.budget.short")); // the empty-state branch needs a band
|
||||
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
|
||||
|
||||
// A press that RESOLVES with nothing left: the honest empty state.
|
||||
discoverPick.mockResolvedValueOnce({ budget: "short", hero: null });
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await screen.findByText("home.hero.emptyBudget");
|
||||
expect(screen.queryByText("Hero v1")).toBeNull();
|
||||
|
||||
// …and a press from there that fails must not undo it.
|
||||
let failPick!: (e: Error) => void;
|
||||
discoverPick.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failPick = reject;
|
||||
}),
|
||||
);
|
||||
const rerollBtn = () =>
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
expect(rerollBtn().disabled).toBe(true); // the press is outstanding
|
||||
|
||||
failPick(new Error("boom"));
|
||||
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
|
||||
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
|
||||
// the press is in flight — so asserting it straight away would pass with or without the fix.
|
||||
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
|
||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false));
|
||||
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
|
||||
expect(screen.queryByText("Hero v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes the payload the bookmark is read from", async () => {
|
||||
mount();
|
||||
await screen.findByText("Second thing");
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getAllByTitle("card.saveForLater")[0]!);
|
||||
// `VideoCard` derives the filled/empty star straight from `video.saved` in this payload and
|
||||
// keeps no optimistic state of its own, so without re-asking, the icon never changed and the
|
||||
// next click re-sent the same call — the control looked dead.
|
||||
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("asks for the chosen budget and keeps the previous answer on screen meanwhile", async () => {
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
// A never-resolving refetch: the point is what the page shows WHILE it waits.
|
||||
discover.mockReturnValue(new Promise(() => {}));
|
||||
fireEvent.click(screen.getByText("home.budget.short"));
|
||||
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
|
||||
// Still the old answer — not a spinner that would take the chips away with it.
|
||||
expect(screen.getByText("Hero v1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rerolls with the shown card excluded, and holds the hero while it loads", async () => {
|
||||
discoverPick.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() =>
|
||||
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
|
||||
);
|
||||
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("never shows the empty state while a reroll is in flight", async () => {
|
||||
// The regression the browser round caught: a new query key has no data for a beat, so the hero
|
||||
// fell to null and the page rendered "nothing to suggest" over a perfectly good library.
|
||||
discoverPick.mockReturnValue(new Promise(() => {}));
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalled());
|
||||
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
|
||||
expect(screen.getByText("Hero v1")).toBeTruthy();
|
||||
});
|
||||
|
||||
// E24 S4. The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
|
||||
// mounted yet — the request has to WAIT as a token rather than fire into an empty room. And it
|
||||
// must be spent: a token that survived would make a later visit reroll on its own, which reads as
|
||||
// the page overruling the answer you came back for.
|
||||
it("acts on a pending surprise request once, and never replays it", async () => {
|
||||
discoverPick.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
requestSurprise();
|
||||
const first = mount();
|
||||
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||
expect(discoverPick).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.unmount();
|
||||
discoverPick.mockClear();
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
expect(discoverPick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
|
||||
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
|
||||
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
|
||||
// taken with a compare-and-clear against the LIVE store, which is what makes the second pass moot.
|
||||
it("does not reroll twice under StrictMode's double-invoked mount effect", async () => {
|
||||
discoverPick.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
requestSurprise();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={qc}>
|
||||
<Home />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||
expect(discoverPick).toHaveBeenCalledTimes(1);
|
||||
// …and on the press count the server reads as "which tier answers first".
|
||||
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
|
||||
});
|
||||
|
||||
it("says the shelf is empty rather than hiding it", async () => {
|
||||
mount();
|
||||
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("plays a half-watched SHOW's next episode instead of opening the show page", async () => {
|
||||
// Reported 2026-09-06. A show tile that opens the show page hands back the very decision this
|
||||
// surface exists to remove — six seasons to choose between. The rail row carries `next`, and the
|
||||
// click follows it.
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [showCard()],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("A Show"));
|
||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
|
||||
});
|
||||
|
||||
it("hands off with `replace` so Back returns HERE, not to the Plex module's root", async () => {
|
||||
// The hand-off is a continuation of the navigation that just pushed the Plex page entry, not a
|
||||
// second step on top of it — without this, Back landed on the Plex Discover page the user never
|
||||
// visited (and, under StrictMode, did nothing at all the first press).
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [showCard()],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("A Show"));
|
||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
|
||||
expect(setPage).toHaveBeenCalledWith("plex");
|
||||
});
|
||||
|
||||
it("sends the ⓘ affordance to the item's page, not to the player", async () => {
|
||||
// `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play
|
||||
// test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play"
|
||||
// typechecked (both are members of `PlexJumpTarget["type"]`) and shipped green. ⓘ is the one
|
||||
// action on this surface a user presses precisely because they do NOT want to start watching.
|
||||
const movie = {
|
||||
...ytCard("m1", "A Film"),
|
||||
source: "plex",
|
||||
kind: "movie",
|
||||
aspect: "poster",
|
||||
video: null,
|
||||
plex: { id: "m1", type: "movie", title: "A Film", thumb: "" },
|
||||
};
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [movie],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByLabelText("plex.info.title"));
|
||||
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true });
|
||||
expect(setPage).toHaveBeenCalledWith("plex");
|
||||
});
|
||||
|
||||
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
|
||||
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
|
||||
// never once reaching the shelf. The press count travels to the server as the rotation.
|
||||
discoverPick.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "comfort", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() =>
|
||||
expect(discoverPick).toHaveBeenLastCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
|
||||
);
|
||||
await screen.findByText("Rerolled");
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() =>
|
||||
expect(discoverPick).toHaveBeenLastCalledWith(
|
||||
"any",
|
||||
expect.any(Number),
|
||||
// The exact list, not `expect.any(Array)`: the window has to ACCUMULATE. A one-element list
|
||||
// is identical whether it accumulates or replaces, so the first press cannot tell them
|
||||
// apart — and with the loose matcher both "replace, never grow" and SEEN_WINDOW = 1 (which
|
||||
// reinstate the measured nine-card cycle) left the whole suite green.
|
||||
["youtube:v1", "youtube:v9"],
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E24 S3: the comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
|
||||
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
|
||||
// sibling test's mocks would leak into.
|
||||
//
|
||||
// What is asserted here is what tsc cannot see: that the menu the user gets over a SHELF tile is a
|
||||
// different menu from the one over a resume tile, that a move row is disabled exactly when the move
|
||||
// would go nowhere, and that a move writes the shelf's WHOLE new order rather than the pair it
|
||||
// swapped.
|
||||
//
|
||||
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, wrap in a FloatingTree,
|
||||
// and match menu items by text with `{ hidden: true }` (floating-ui never positions in jsdom, so the
|
||||
// surface stays visibility:hidden and its contents have no accessible name).
|
||||
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const discover = vi.fn();
|
||||
const comfortPins = vi.fn();
|
||||
const addComfortPin = vi.fn();
|
||||
const removeComfortPin = vi.fn();
|
||||
const reorderComfortPins = vi.fn();
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: {
|
||||
discover: (...a: unknown[]) => discover(...a),
|
||||
discoverPick: () => new Promise(() => {}),
|
||||
setState: () => Promise.resolve(),
|
||||
watchLaterAdd: () => Promise.resolve(),
|
||||
watchLaterRemove: () => Promise.resolve(),
|
||||
comfortPins: (...a: unknown[]) => comfortPins(...a),
|
||||
addComfortPin: (...a: unknown[]) => addComfortPin(...a),
|
||||
removeComfortPin: (...a: unknown[]) => removeComfortPin(...a),
|
||||
reorderComfortPins: (...a: unknown[]) => reorderComfortPins(...a),
|
||||
},
|
||||
HttpError: class extends Error {},
|
||||
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
|
||||
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||
}));
|
||||
|
||||
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
|
||||
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
|
||||
|
||||
import Home from "./Home";
|
||||
|
||||
const video = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
channel_id: "UC1",
|
||||
channel_title: "A channel",
|
||||
channel_thumbnail: null,
|
||||
channel_url: "https://youtube.com/channel/UC1",
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
thumbnail_url: null,
|
||||
duration_seconds: 600,
|
||||
view_count: 10,
|
||||
is_short: false,
|
||||
live_status: "none",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
saved: false,
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
});
|
||||
|
||||
const base = (id: string, title: string) => ({
|
||||
source: "youtube",
|
||||
kind: "video",
|
||||
id,
|
||||
title,
|
||||
subtitle: null,
|
||||
thumb: null,
|
||||
aspect: "wide",
|
||||
duration_seconds: 600,
|
||||
position_seconds: 0,
|
||||
remaining_seconds: 600,
|
||||
status: "new",
|
||||
plex: null,
|
||||
video: video(id, title),
|
||||
});
|
||||
|
||||
/** A pinned CHANNEL — the shelf tile with no native card, and the one that is easiest to address by
|
||||
* its title in a test (the video tiles render a whole VideoCard). */
|
||||
const channelPin = (id: string, title: string, pinId: number) => ({
|
||||
...base(id, title),
|
||||
kind: "channel",
|
||||
video: null,
|
||||
unwatched_count: 3,
|
||||
pin_id: pinId,
|
||||
next: base(`${id}-next`, `${title} next`),
|
||||
});
|
||||
|
||||
const shelf = [
|
||||
channelPin("UCa", "Comfort A", 3),
|
||||
channelPin("UCb", "Comfort B", 7),
|
||||
channelPin("UCc", "Comfort C", 9),
|
||||
];
|
||||
|
||||
const payload = () => ({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [base("v2", "Resume thing")],
|
||||
comfort: shelf,
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<FloatingTree>
|
||||
<QueryClientProvider client={qc}>
|
||||
<Home />
|
||||
</QueryClientProvider>
|
||||
</FloatingTree>,
|
||||
);
|
||||
}
|
||||
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found as HTMLButtonElement;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
discover.mockReset().mockResolvedValue(payload());
|
||||
comfortPins.mockReset().mockResolvedValue({ items: shelf });
|
||||
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
|
||||
removeComfortPin.mockReset().mockResolvedValue({ deleted: 1 });
|
||||
reorderComfortPins.mockReset().mockResolvedValue({ ok: true });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Home — the comfort shelf's context menu", () => {
|
||||
it("offers the four moves and the unpin over a SHELF tile", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort B"));
|
||||
expect(labels()).toEqual([
|
||||
"home.hero.play",
|
||||
"home.shelf.moveFirst",
|
||||
"home.shelf.moveLeft",
|
||||
"home.shelf.moveRight",
|
||||
"home.shelf.moveLast",
|
||||
"home.pin.remove",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables exactly the moves that would go nowhere", async () => {
|
||||
mount();
|
||||
// The first tile can go right, but not left or to the front it is already at.
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
expect(item("home.shelf.moveFirst").disabled).toBe(true);
|
||||
expect(item("home.shelf.moveLeft").disabled).toBe(true);
|
||||
expect(item("home.shelf.moveRight").disabled).toBe(false);
|
||||
expect(item("home.shelf.moveLast").disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the WHOLE new order when a tile moves", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
// Not the swapped pair: the endpoint takes the shelf's complete order, and a short list would be
|
||||
// the server reordering half the shelf.
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
|
||||
});
|
||||
|
||||
it("moves the tile under the pointer, not only in the request", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
// Hold the refetch that follows the write: it would answer with this mock's ORIGINAL order,
|
||||
// which is not what is under test. The claim here is that the tile moves under the pointer,
|
||||
// before any round trip — `keepPreviousData` keeps the patched cache on screen meanwhile.
|
||||
discover.mockReturnValue(new Promise(() => {}));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalled());
|
||||
// The RENDERED order, not just the payload sent: drop the optimistic cache write and the request
|
||||
// is still perfectly right while the shelf visibly does not move, so the user clicks again.
|
||||
expect(screen.getAllByText(/^Comfort [ABC]$/).map((e) => e.textContent)).toEqual([
|
||||
"Comfort B",
|
||||
"Comfort A",
|
||||
"Comfort C",
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-asks the server when a move fails instead of restoring a snapshot", async () => {
|
||||
// Writing a captured `before` back was wrong twice: two overlapping moves each reverted to their
|
||||
// own snapshot and the shelf settled on an order neither side held, and `before` is the WHOLE
|
||||
// payload, so the rollback also discarded any rail refresh that landed mid-flight.
|
||||
reorderComfortPins.mockRejectedValue(new Error("nope"));
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
fireEvent.contextMenu(screen.getByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("unpins with the pin id the shelf card carries", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort C"));
|
||||
fireEvent.click(item("home.pin.remove"));
|
||||
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(9));
|
||||
});
|
||||
|
||||
it("offers the plain PIN over a resume tile, and never a move", async () => {
|
||||
// The rail below the shelf is not orderable, so its menu must not grow rows that would reorder
|
||||
// something the tile is not part of.
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
fireEvent.contextMenu(screen.getByText("Resume thing"));
|
||||
await waitFor(() => expect(labels()).toContain("home.pin.add"));
|
||||
expect(labels().some((l) => l?.startsWith("home.shelf.move"))).toBe(false);
|
||||
fireEvent.click(item("home.pin.add"));
|
||||
await waitFor(() => expect(addComfortPin).toHaveBeenCalledWith("video", "v2"));
|
||||
});
|
||||
|
||||
it("still opens the menu over a shelf tile that has nothing left to play", async () => {
|
||||
// The tile with nothing left to play is the one you most want to take OFF the shelf, so its
|
||||
// menu still has to open and its Play row still has to be honest about being dead.
|
||||
// NOTE what this does NOT prove: jsdom delivers `contextmenu` to a real `<button disabled>`
|
||||
// too, so swapping the tile's `aria-disabled` back to `disabled` keeps this test green. That
|
||||
// choice was settled in a real browser instead (see the tile's own comment) — the difference
|
||||
// is focus, which jsdom's event dispatch never exercises.
|
||||
discover.mockResolvedValue({
|
||||
...payload(),
|
||||
comfort: [{ ...channelPin("UCd", "Nothing left", 11), next: null }],
|
||||
});
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Nothing left"));
|
||||
expect(item("home.hero.play").disabled).toBe(true);
|
||||
fireEvent.click(item("home.pin.remove"));
|
||||
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
|
||||
});
|
||||
|
||||
it("says so rather than guessing while the shelf is still loading", async () => {
|
||||
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
|
||||
// trustworthy once the shelf has answered.
|
||||
comfortPins.mockReturnValue(new Promise(() => {}));
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
fireEvent.contextMenu(screen.getByText("Resume thing"));
|
||||
expect(item("home.pin.add").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
PinOff,
|
||||
Play,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
DISCOVER_BUDGETS,
|
||||
discoverCardKey,
|
||||
type DiscoverBudget,
|
||||
type DiscoverCard,
|
||||
type DiscoverPayload,
|
||||
type HeroTier,
|
||||
type PlexCard,
|
||||
type Video,
|
||||
type VideoStatus,
|
||||
} from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
||||
import { ComfortPinMenu } from "./ComfortPinMenu";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import VideoCard from "./VideoCard";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { usePlexActions } from "./PlexProvider";
|
||||
|
||||
// The Home surface (E24) — "Mit nézzek most?".
|
||||
//
|
||||
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
|
||||
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
|
||||
// promise is that it ANSWERS rather than offers, which is why there is one hero and not a grid of
|
||||
// three: a shortlist is still a comparison, and comparing is the thing the user cannot do on the
|
||||
// evenings this page exists for.
|
||||
//
|
||||
// Everything domain-shaped is already built: the cards are the feed's `VideoCard` and Plex's
|
||||
// `PlexPosterCard`, rendered from the native payload each unified card carries, and the rails are
|
||||
// the platform `Carousel`. The two PLAYABLE card faces are reused rather than re-drawn; the only
|
||||
// card this file defines is `ShelfTile`, because a pinned channel or playlist has no card anywhere
|
||||
// in the app and `PosterTile`'s art is hardcoded to a Plex image URL.
|
||||
|
||||
// Lazy, exactly as the feed does it: the YouTube IFrame player loads only once something is played.
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
// From the api slice's own list, not a second copy: `readonly DiscoverBudget[]` accepts any
|
||||
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
|
||||
// silently kept offering four.
|
||||
|
||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
||||
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
||||
// drift from it.
|
||||
const YT_SLOT = 200;
|
||||
|
||||
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
||||
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
|
||||
// before the resume rail (20 rows) was exhausted, so the button cycled the same nine cards forever
|
||||
// (measured, 2026-09-06). It is bounded at all only because the server will not scan an unbounded
|
||||
// exclusion list.
|
||||
const SEEN_WINDOW = 32;
|
||||
|
||||
// The four shelf moves, in the order they appear in the menu.
|
||||
const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
|
||||
["first", "home.shelf.moveFirst", ChevronsLeft],
|
||||
["left", "home.shelf.moveLeft", ChevronLeft],
|
||||
["right", "home.shelf.moveRight", ChevronRight],
|
||||
["last", "home.shelf.moveLast", ChevronsRight],
|
||||
];
|
||||
|
||||
function Rail({
|
||||
title,
|
||||
ariaLabel,
|
||||
cards,
|
||||
emptyLabel,
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
onMove,
|
||||
}: {
|
||||
title: string;
|
||||
ariaLabel: string;
|
||||
cards: DiscoverCard[];
|
||||
emptyLabel: string;
|
||||
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
|
||||
onInfo: (card: DiscoverCard) => void;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onToggleWatched: (card: PlexCard) => void;
|
||||
/** Only the comfort shelf is orderable. Given, this rail's menu grows the four move rows; omitted,
|
||||
* its tiles offer the plain pin instead. */
|
||||
onMove?: (card: DiscoverCard, to: ShelfMove) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
|
||||
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
|
||||
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
|
||||
const menu = useContextMenu<DiscoverCard>();
|
||||
const pin = useComfortPin(menu.open);
|
||||
const target = menu.target;
|
||||
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
|
||||
const items: ActionMenuItem[] = !target
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "play",
|
||||
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
|
||||
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
|
||||
// than dropped, so the menu does not change shape from tile to tile.
|
||||
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
||||
onSelect: () => onPlay(target),
|
||||
},
|
||||
...(onMove && target.pin_id != null
|
||||
? [
|
||||
...SHELF_MOVES.map(([to, key, Icon], i) => ({
|
||||
id: `shelf-${to}`,
|
||||
label: t(key),
|
||||
icon: <Icon className="w-4 h-4" />,
|
||||
separatorBefore: i === 0,
|
||||
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
||||
disabled: shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
||||
onSelect: () => onMove(target, to),
|
||||
})),
|
||||
{
|
||||
id: "shelf-unpin",
|
||||
label: t("home.pin.remove"),
|
||||
icon: <PinOff className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
// The only row here that removes a curated entry, and it sat in the same colour as
|
||||
// the four harmless reorders. `danger` matches the shared unpin row in
|
||||
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
|
||||
// rail below) composes for the same command — the two must not disagree.
|
||||
danger: true,
|
||||
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
||||
// other surface it can never be in the "not known yet" state.
|
||||
onSelect: () => pin.remove(target.pin_id as number, target.title),
|
||||
},
|
||||
]
|
||||
: pin.item(pinTargetForCard(target), target.title)),
|
||||
];
|
||||
|
||||
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
|
||||
// you have not pinned anything teaches nothing about how to fill it.
|
||||
if (!cards.length) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-sm font-semibold mb-2">{title}</h2>
|
||||
<StateMessage compact>{emptyLabel}</StateMessage>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<Carousel
|
||||
title={<span className="text-sm font-semibold">{title}</span>}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={`${c.source}:${c.kind}:${c.id}`}
|
||||
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
|
||||
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
||||
>
|
||||
{c.video ? (
|
||||
<VideoCard
|
||||
video={c.video}
|
||||
view="cardsSmall"
|
||||
width={YT_SLOT}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||||
// Bound to the UNIFIED card, not the native payload the card component holds: the
|
||||
// menu acts on the shelf ENTRY (its pin id, its place in the order), which the
|
||||
// `Video` inside it does not carry.
|
||||
menuProps={() => menu.triggerProps(c)}
|
||||
/>
|
||||
) : c.plex ? (
|
||||
<PlexPosterCard
|
||||
card={c.plex}
|
||||
onOpen={() => onPlay(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
menuProps={() => menu.triggerProps(c)}
|
||||
/>
|
||||
) : (
|
||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A pinned CHANNEL or PLAYLIST has no native card of its own — neither is a playable thing, so
|
||||
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
|
||||
// runtime it does not have.
|
||||
function ShelfTile({
|
||||
card,
|
||||
onPlay,
|
||||
menuProps,
|
||||
}: {
|
||||
card: DiscoverCard;
|
||||
onPlay: (card: DiscoverCard) => void;
|
||||
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||
const countLabel =
|
||||
card.kind === "channel"
|
||||
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
||||
: t("home.shelf.itemCount", { count: count ?? 0 });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => card.next && onPlay(card)}
|
||||
{...menuProps?.(card)}
|
||||
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
||||
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
|
||||
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
||||
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
|
||||
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
|
||||
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
||||
// alive to all three input paths.
|
||||
aria-disabled={!card.next}
|
||||
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
|
||||
>
|
||||
<span className="block aspect-video bg-card overflow-hidden">
|
||||
{card.thumb ? (
|
||||
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block p-2">
|
||||
<span className="block text-xs font-medium truncate">{card.title}</span>
|
||||
<span className="block text-2xs text-muted">{countLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({
|
||||
tier,
|
||||
card,
|
||||
onPlay,
|
||||
onReroll,
|
||||
rerolling,
|
||||
}: {
|
||||
tier: HeroTier;
|
||||
card: DiscoverCard;
|
||||
onPlay: (card: DiscoverCard) => void;
|
||||
onReroll: () => void;
|
||||
rerolling: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
|
||||
// answered with — so it answers the same right-click every card in the app does.
|
||||
const pinMenu = useContextMenu<DiscoverCard>();
|
||||
const started = card.position_seconds > 0;
|
||||
const left = card.remaining_seconds;
|
||||
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
|
||||
// you left off" over something never opened is exactly the quiet lie the house rules forbid.
|
||||
const kicker =
|
||||
tier === "resume"
|
||||
? left != null
|
||||
? t("home.hero.resumeLeft", { time: formatDuration(left) })
|
||||
: t("home.hero.resume")
|
||||
: tier === "comfort"
|
||||
? t("home.hero.comfort")
|
||||
: t("home.hero.surprise");
|
||||
|
||||
return (
|
||||
<section
|
||||
{...pinMenu.triggerProps(card)}
|
||||
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlay(card)}
|
||||
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
|
||||
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
|
||||
}`}
|
||||
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
||||
>
|
||||
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
|
||||
{card.duration_seconds != null && (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
|
||||
{formatDuration(card.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{started && card.duration_seconds ? (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
||||
<span
|
||||
className="block h-full bg-accent"
|
||||
style={{
|
||||
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
||||
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
|
||||
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
|
||||
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
|
||||
{started ? t("home.hero.continue") : t("home.hero.play")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<RotateCcw className="h-4 w-4" />}
|
||||
loading={rerolling}
|
||||
onClick={onReroll}
|
||||
>
|
||||
{t("home.hero.reroll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => c.title} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { setPage } = useNavigationActions();
|
||||
const { setJumpTo } = usePlexActions();
|
||||
|
||||
const [budget, setBudget] = useState<DiscoverBudget>("any");
|
||||
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
|
||||
// pressing the button does not refetch the rails under the user's cursor.
|
||||
const [seed, setSeed] = useState(0);
|
||||
// Which press this is. It rotates the tier the server leads with, so "Mást kérek" offers a
|
||||
// different KIND of answer each time (where you left off → your shelf → a surprise) rather than
|
||||
// the next row of the same rail. Reset with the budget: a new question starts the rotation over.
|
||||
const [press, setPress] = useState(0);
|
||||
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
|
||||
// the whole history: an unbounded list would eventually exclude everything and leave the button
|
||||
// with nothing to give, whereas a bounded one cannot. Its size is `SEEN_WINDOW` — see there for
|
||||
// why it is 32 and not the 8 this comment used to quote. Kept here (not in the URL or a pref)
|
||||
// because it is a property of this sitting.
|
||||
const [seen, setSeen] = useState<string[]>([]);
|
||||
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: qk.discover(budget),
|
||||
queryFn: () => api.discover(budget, 0),
|
||||
// Keep the previous budget's answer on screen while the new one loads. Without this the page
|
||||
// falls back to `isPending` and replaces itself — chips included — with a spinner, so the
|
||||
// control you just clicked vanishes under your cursor. (Same reason the feed does it.)
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const pickQ = useQuery({
|
||||
queryKey: qk.discoverPick(budget, seed, seen, press),
|
||||
queryFn: () => api.discoverPick(budget, seed, seen, press),
|
||||
enabled: seed !== 0,
|
||||
staleTime: 0,
|
||||
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
|
||||
// has no data for a beat, `hero` falls to null, and the page renders "nothing to suggest yet" —
|
||||
// an EMPTY state standing in for a loading one, which is the exact dishonesty the house rules
|
||||
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
|
||||
//
|
||||
// But only WITHIN a budget. Plain `keepPreviousData` chains through the disabled seed-0 query a
|
||||
// chip click leaves behind, so the first reroll under a new budget held up the card the PREVIOUS
|
||||
// question was answered with — under a chip promising something else (round 2). Returning
|
||||
// undefined instead falls the hero back to this budget's own loaded answer, which is already in
|
||||
// hand; the spinner still covers the wait.
|
||||
placeholderData: (prev) => (prev?.budget === budget ? prev : undefined),
|
||||
});
|
||||
// Is a PRESS outstanding? NOT `pickQ.isFetching`. The pick key deliberately sits under the
|
||||
// `["discover"]` prefix so a mutation's invalidation reaches the rerolled hero — which also means
|
||||
// every mark-watched, bookmark, pin/unpin and shelf move on this page refetches it in the
|
||||
// background, and `ui/Button` turns `loading` into `disabled`. So the reroll spun and swallowed
|
||||
// clicks after actions the user never associated with it (round 2). A press is outstanding exactly
|
||||
// while THIS key has never settled — which is what `isFetched` means (react-query counts both a
|
||||
// success and an error as settled), and every press mints a fresh seed, so a fresh key.
|
||||
//
|
||||
// Round 3 corrected this: `isPlaceholderData || data === undefined` looked equivalent and was not.
|
||||
// A pick that FAILED leaves its key data-less forever (`retry: false`), so the second disjunct
|
||||
// stayed true and the next unrelated mutation re-disabled the button — the same defect, surviving
|
||||
// in the error branch. Guarding on `!isError` does NOT help either: query-core resets a data-less
|
||||
// query to status "pending" for the duration of a refetch, so the error is invisible exactly when
|
||||
// it matters. `isFetched` is the only flag that remembers.
|
||||
const rerolling = pickQ.isFetching && !pickQ.isFetched;
|
||||
// The last pick that actually RESOLVED, tagged with the budget it answered. Every press is a new
|
||||
// query key, so a failed one has no data of its own and `keepPreviousData` (pending-only) cannot
|
||||
// cover it — without this the hero silently rewound to the card the page loaded with, several
|
||||
// presses back, while `core.req`'s own dialog was explaining the failure. A reroll that fails
|
||||
// leaves the answer on screen alone.
|
||||
//
|
||||
// The TAG is load-bearing, not bookkeeping: the chip row resets `seed`/`seen`/`press` but cannot
|
||||
// reset an answer that already resolved, so an untagged slot outlived the question it answered —
|
||||
// switch to "10 perc", press reroll, let that request fail (`retry: false`, so it is terminal),
|
||||
// and the two-hour film picked under "Bármennyi" was still the hero, under a chip promising ten
|
||||
// minutes (round 2). Taken from the SERVER's `budget` echo rather than this render's state, so the
|
||||
// tag cannot be minted from a budget the pick never answered.
|
||||
const [lastPick, setLastPick] = useState<{
|
||||
budget: DiscoverBudget;
|
||||
hero: DiscoverPayload["hero"];
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
|
||||
}, [pickQ.data]);
|
||||
|
||||
const data = q.data;
|
||||
// Which hero is on screen. The middle branch is the load-bearing one: `keepPreviousData` holds the
|
||||
// PREVIOUS reroll, but the FIRST reroll has no previous pick to hold, so without falling back to
|
||||
// the loaded payload the page renders "nothing to suggest" over a perfectly good library for a
|
||||
// beat. `pickQ.data !== undefined` (not `?? null`) is what keeps a genuinely empty answer honest:
|
||||
// once the pick has RESOLVED, a null hero really means nothing is left and the empty state is the
|
||||
// truth.
|
||||
const hero =
|
||||
seed === 0
|
||||
? (data?.hero ?? null)
|
||||
: pickQ.data !== undefined
|
||||
? pickQ.data.hero
|
||||
: // A ternary, not `??`: the tagged slot is AUTHORITATIVE for its own budget, and "the last
|
||||
// pick for this budget resolved to no hero" is a real answer, not a missing one. Under
|
||||
// `??` the two collapsed, so a failed press after an empty pick resurrected the card the
|
||||
// page loaded with — which it had already excluded as seen and just said it had nothing
|
||||
// beyond (round 3). A mismatched or absent tag still falls through to the loaded hero,
|
||||
// which is what the FIRST press of a budget needs.
|
||||
lastPick?.budget === budget
|
||||
? lastPick.hero
|
||||
: (data?.hero ?? null);
|
||||
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
|
||||
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
|
||||
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
|
||||
//
|
||||
// Against BOTH of a row's keys, the way `hero.pick`'s own exclusion does. For a Plex series the
|
||||
// rail row is the SHOW while the hero is the episode that show RESOLVES to, so a single-key match
|
||||
// never fires and the most common Plex resume case printed the series twice.
|
||||
const heroKey = hero ? discoverCardKey(hero.card) : null;
|
||||
const resumeRail = (data?.continue_watching ?? []).filter(
|
||||
(c) => discoverCardKey(c) !== heroKey && discoverCardKey(c.next ?? c) !== heroKey,
|
||||
);
|
||||
// Empty because the hero ATE the rail, not because nothing is half-finished. An empty rail earns
|
||||
// its empty state by teaching you how to fill it; here there is nothing to teach, and "Nothing
|
||||
// half-finished right now" under a hero captioned "Where you left off" is the page contradicting
|
||||
// itself on one screen.
|
||||
const resumeConsumed = !resumeRail.length && (data?.continue_watching ?? []).length > 0;
|
||||
|
||||
const onState = useCallback(
|
||||
(id: string, status: VideoStatus) => {
|
||||
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
|
||||
// something watched genuinely changes what the page should answer (it can retire the hero).
|
||||
// The feed avoids this because it would refetch ten infinite-query pages per click.
|
||||
void api
|
||||
.setState(id, status)
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
|
||||
// Without this the rejection escapes unhandled and the click just does nothing — the
|
||||
// feed's equivalents both catch, and the reorder on this same page already does.
|
||||
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
|
||||
// your suggestions"), and it is what a detail-less rejection actually shows — an offline
|
||||
// click raises a bare fetch TypeError, so there is no server reason to prefer over it. It
|
||||
// would describe a state the user can see is untrue, over a fully populated page whose
|
||||
// invalidations never even ran (they sit inside `.then`).
|
||||
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
|
||||
},
|
||||
[qc, t],
|
||||
);
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
(id: string, saved: boolean) => {
|
||||
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
// And the payload the star is READ from. `VideoCard` derives the filled/empty bookmark
|
||||
// straight from `video.saved` and keeps no optimistic state of its own (the feed instead
|
||||
// holds a `savedOverrides` map), so without this the icon never changed and a second click
|
||||
// re-sent the same call — the control looked dead.
|
||||
qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
})
|
||||
// The write key, for the same reason as `onState` above.
|
||||
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
|
||||
},
|
||||
[qc, t],
|
||||
);
|
||||
|
||||
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would
|
||||
// start, a channel → its newest unwatched video); everything else plays itself. Plex hands off to
|
||||
// its own module, which owns the hls.js player and its whole provider tree — the alternative would
|
||||
// be a second player mounted on the landing page.
|
||||
const onPlay = useCallback(
|
||||
(card: DiscoverCard, startAt?: number | null) => {
|
||||
const target = card.next ?? card;
|
||||
if (target.video) {
|
||||
setPlayer({ video: target.video, startAt: startAt ?? null });
|
||||
return;
|
||||
}
|
||||
if (target.plex) {
|
||||
// `replace`: the Plex module's sub-view rewrites the page entry `setPage` is about to push,
|
||||
// so browser-Back from the player returns HERE — not to the Plex Discover root, which this
|
||||
// navigation passed through and the user never saw.
|
||||
setJumpTo({
|
||||
id: target.plex.id,
|
||||
type: target.plex.type === "show" ? "show" : "play",
|
||||
replace: true,
|
||||
});
|
||||
setPage("plex");
|
||||
}
|
||||
},
|
||||
[setJumpTo, setPage],
|
||||
);
|
||||
|
||||
// The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same
|
||||
// click goes from the Plex grid. A show tile's card click lands there too — a show is not playable.
|
||||
const onInfo = useCallback(
|
||||
(card: DiscoverCard) => {
|
||||
if (!card.plex) return;
|
||||
setJumpTo({
|
||||
id: card.plex.id,
|
||||
type: card.plex.type === "show" ? "show" : "movie",
|
||||
replace: true,
|
||||
});
|
||||
setPage("plex");
|
||||
},
|
||||
[setJumpTo, setPage],
|
||||
);
|
||||
|
||||
// Watched-toggle on a Plex tile. No optimistic cache patch here (unlike the Plex Discover
|
||||
// surface): marking something watched can retire the hero as well as the tile, so this page
|
||||
// re-asks the server rather than trying to predict a cross-rail consequence.
|
||||
const onToggleWatched = useCallback(
|
||||
(card: PlexCard) =>
|
||||
void toggleWatchState(card, t, {
|
||||
optimistic: () => {},
|
||||
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
|
||||
}),
|
||||
[qc, t],
|
||||
);
|
||||
|
||||
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
|
||||
// tile moves under the pointer, then written; a failure re-asks the server rather than leaving the
|
||||
// page showing an order the server never accepted (the same lesson the playlist reorder learned).
|
||||
const moveInShelf = useCallback(
|
||||
(card: DiscoverCard, to: ShelfMove) => {
|
||||
const key = qk.discover(budget);
|
||||
const before = qc.getQueryData<DiscoverPayload>(key);
|
||||
const cards = before?.comfort ?? [];
|
||||
const from = cards.findIndex((c) => c.pin_id === card.pin_id);
|
||||
const target = shelfMoveTarget(from, to, cards.length);
|
||||
if (target < 0) return;
|
||||
const next = [...cards];
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (!moved) return; // unreachable: `from` is in range whenever `target` is
|
||||
next.splice(target, 0, moved);
|
||||
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: next } : old));
|
||||
// `pin_id` is present on every shelf card by construction; the filter is what proves it to the
|
||||
// type, and a short list would be rejected by the server rather than silently reordering half
|
||||
// the shelf.
|
||||
const ids = next.map((c) => c.pin_id).filter((id): id is number => id != null);
|
||||
void api
|
||||
.reorderComfortPins(ids)
|
||||
.catch((e: unknown) => notifyActionError(e, t("home.shelf.moveFailed")))
|
||||
.finally(() => {
|
||||
// The shelf is also the pin menus' source of truth everywhere else in the app.
|
||||
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
|
||||
// And the PREFIX, not this budget's key — which is what actually re-asks the server, and
|
||||
// is what the comment above this callback promises. Restoring the captured snapshot
|
||||
// instead was wrong twice over: two overlapping moves each reverted to their own
|
||||
// snapshot, so the shelf settled on an order neither side ever held; and `before` is the
|
||||
// whole payload, so a rollback also threw away any rail refresh that had landed while the
|
||||
// request was in flight. On the success path the prefix matters for a second reason — the
|
||||
// shelf is budget-independent server-side but cached per budget, so without it the next
|
||||
// chip click renders the pre-move order and a move from there writes it back.
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
},
|
||||
[budget, qc, t],
|
||||
);
|
||||
|
||||
const reroll = useCallback(() => {
|
||||
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
|
||||
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
|
||||
// would look like a dead button).
|
||||
if (heroKey)
|
||||
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
|
||||
setPress((n) => n + 1);
|
||||
setSeed((prev) => {
|
||||
let next = prev;
|
||||
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
|
||||
return next;
|
||||
});
|
||||
}, [heroKey]);
|
||||
|
||||
// "Meglepetés" from the command palette: the request is made on another page, so it waits as a
|
||||
// token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
|
||||
// is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
|
||||
// false to every later pass, including StrictMode's second mount invocation and the re-runs
|
||||
// `reroll`'s changing identity causes.
|
||||
const surpriseRequest = useSurpriseRequest();
|
||||
useEffect(() => {
|
||||
if (surpriseRequest && takeSurprise()) reroll();
|
||||
}, [surpriseRequest, reroll]);
|
||||
|
||||
if (q.isPending) return <LoadingState />;
|
||||
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
|
||||
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
|
||||
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
|
||||
// blip replaced a fully populated landing page with an error line.
|
||||
if (q.isError && !data)
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
||||
{t("home.error")}
|
||||
</StateMessage>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{/* The caption sits NEXT TO the chips, not opposite them: SegmentedToggle's own `label` prop
|
||||
renders a justify-between row, which is right for a narrow filter rail and wrong here — on
|
||||
an 80rem page it throws the answers to the far edge, away from the question they answer. */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className="text-sm text-muted">{t("home.budget.ask")}</span>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("home.budget.ask")}
|
||||
value={budget}
|
||||
onChange={(v) => {
|
||||
setBudget(v);
|
||||
setSeed(0); // the payload for the new budget carries its own hero
|
||||
setSeen([]); // a new question deserves the whole library again
|
||||
setPress(0); // …and the tier rotation starts from the page's own priority order
|
||||
}}
|
||||
options={DISCOVER_BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hero ? (
|
||||
<Hero
|
||||
tier={hero.tier}
|
||||
card={hero.card}
|
||||
onPlay={onPlay}
|
||||
onReroll={reroll}
|
||||
rerolling={rerolling}
|
||||
/>
|
||||
) : (
|
||||
// Honest: nothing in the library fits what was asked. Offering something that does not fit
|
||||
// would make the one control on this page meaningless.
|
||||
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
|
||||
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
|
||||
a re-run of something that failed. Nothing failed — the library simply has no answer
|
||||
this short, and the honest offer is to ask a different question. */}
|
||||
<StateMessage compact>
|
||||
{budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")}
|
||||
</StateMessage>
|
||||
{budget !== "any" && (
|
||||
<div className="flex justify-center pb-4">
|
||||
{/* The same action as the Hero's own reroll, so it carries the same in-flight signal.
|
||||
Without it the press was unacknowledged — `hero` stays null for the whole round
|
||||
trip in this branch, so nothing on the page moved — and a second click spent
|
||||
another pick AND skipped a tier in the server's `start=` rotation. The icon comes
|
||||
along because `loading` swaps it for the spinner: with no icon the button would
|
||||
grow by a spinner's width mid-press. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
type="button"
|
||||
icon={<RotateCcw className="h-4 w-4" />}
|
||||
loading={rerolling}
|
||||
onClick={reroll}
|
||||
>
|
||||
{t("home.hero.reroll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Rail
|
||||
title={t("home.comfort.title")}
|
||||
ariaLabel={t("home.comfort.title")}
|
||||
cards={data?.comfort ?? []}
|
||||
emptyLabel={t("home.comfort.empty")}
|
||||
onPlay={onPlay}
|
||||
onInfo={onInfo}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onMove={moveInShelf}
|
||||
/>
|
||||
{!resumeConsumed && (
|
||||
<Rail
|
||||
title={t("home.continue.title")}
|
||||
ariaLabel={t("home.continue.title")}
|
||||
cards={resumeRail}
|
||||
emptyLabel={t("home.continue.empty")}
|
||||
onPlay={onPlay}
|
||||
onInfo={onInfo}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onToggleWatched={onToggleWatched}
|
||||
/>
|
||||
)}
|
||||
|
||||
{player && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={player.video}
|
||||
startAt={player.startAt}
|
||||
onClose={() => {
|
||||
setPlayer(null);
|
||||
// The player saves progress on close, so the rails and the hero are stale by
|
||||
// definition once it shuts.
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
}}
|
||||
onState={onState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||
@@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelReset}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="outline" onClick={cancelReset} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={doReset}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-danger/90 text-danger-fg hover:bg-danger transition disabled:opacity-40"
|
||||
icon={<AlertTriangle className="w-4 h-4" />}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t("messages.resetButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -157,14 +154,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !pass}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
{configured && (
|
||||
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
||||
{t("messages.forgotPass")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Globe } from "lucide-react";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { jobTitle } from "./downloadRow";
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { openChat } from "../lib/chatDock";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { inputCls } from "./ui/form";
|
||||
import ChatThread from "./ChatThread";
|
||||
@@ -123,13 +124,10 @@ function ConversationList({
|
||||
{t("messages.conversations")}
|
||||
</div>
|
||||
{ready && (
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="xs" onClick={onNew}>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
{t("messages.new")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
||||
@@ -213,12 +211,15 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -280,12 +281,15 @@ function PageThread({
|
||||
return (
|
||||
<div className="flex flex-col h-[65vh] min-h-80">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<Avatar
|
||||
src={seedAvatar}
|
||||
fallback={name}
|
||||
|
||||
@@ -12,10 +12,11 @@ import type { Page } from "../lib/urlState";
|
||||
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "./Avatar";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Button } from "./ui/Button";
|
||||
import AvatarImg from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||
@@ -221,22 +222,22 @@ export default function NavSidebar({
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
|
||||
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onOpenAbout}
|
||||
title={t("header.account.about")}
|
||||
aria-label={t("header.account.about")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Info className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
icon={<Info className="w-5 h-5" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
title={t("header.account.signOut")}
|
||||
aria-label={t("header.account.signOut")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
icon={<LogOut className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered
|
||||
// before this. `setPage` early-returns when the requested page is already current with no channel
|
||||
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to
|
||||
// decide whether the overlay entries beneath a navigation are still reclaimable. Get it wrong and
|
||||
// the round-2 bug is back: Shift+W over the player on Home buries an entry nothing pushed past, and
|
||||
// the user's next browser Back does nothing.
|
||||
//
|
||||
// The type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one
|
||||
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail
|
||||
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the
|
||||
// early return skips, which is what the boolean actually stands for.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the
|
||||
// import graph pulls in `src/i18n`, which initialises against the real `initReactI18next` — so a
|
||||
// wholesale mock throws before any test runs.
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
);
|
||||
const render = () =>
|
||||
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||
});
|
||||
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
|
||||
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("NavigationProvider · setPage reports whether it navigated", () => {
|
||||
it("returns true and pushes an entry for a real page change", () => {
|
||||
const { result } = render();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage("plex");
|
||||
});
|
||||
expect(reported).toBe(true);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.page).toBe("plex");
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false and pushes nothing when it is already that page", () => {
|
||||
const { result } = render();
|
||||
act(() => {
|
||||
result.current.setPage("plex");
|
||||
});
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage("plex");
|
||||
});
|
||||
// Both halves matter: `false` is what `buryUnder` reads, and "pushed nothing" is what makes
|
||||
// that answer true. A value-only assertion would pass against an inverted pair.
|
||||
expect(reported).toBe(false);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true even for the current page when a channel page is over it", () => {
|
||||
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
|
||||
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
|
||||
const { result } = render();
|
||||
// The page under test must be the one that is ALREADY current, or the guard's first conjunct is
|
||||
// false and this never reaches the clause it is named for — the mistake round 4 caught here: a
|
||||
// bare URL with cleared storage lands on "home" (navigationInit's E24 default), not "feed", so
|
||||
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
|
||||
const current = result.current.page;
|
||||
expect(current).toBe("home");
|
||||
act(() => {
|
||||
result.current.openChannel("UC1", "A channel");
|
||||
});
|
||||
expect(result.current.channelView).not.toBeNull();
|
||||
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage(current);
|
||||
});
|
||||
expect(reported).toBe(true);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.channelView).toBeNull();
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,10 @@ export interface NavigationState {
|
||||
}
|
||||
|
||||
export interface NavigationActions {
|
||||
setPage: (next: Page) => void;
|
||||
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page
|
||||
* is already current with no channel page over it, and a caller that buries overlay history
|
||||
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */
|
||||
setPage: (next: Page) => boolean;
|
||||
openChannel: (id: string, name?: string) => void;
|
||||
closeChannel: () => void;
|
||||
enterYtSearch: (q: string) => void;
|
||||
@@ -50,7 +53,7 @@ const StateContext = createContext<NavigationState>({
|
||||
ytSearch: null,
|
||||
});
|
||||
const ActionsContext = createContext<NavigationActions>({
|
||||
setPage: () => {},
|
||||
setPage: () => false,
|
||||
openChannel: () => {},
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
@@ -118,7 +121,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
(next: Page) => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return;
|
||||
if (next === pageRef.current && !channelViewRef.current) return false;
|
||||
markDirty();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
@@ -147,6 +150,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
return true;
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
@@ -37,9 +37,10 @@ import {
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||
import { LEVEL_STYLE } from "./Toaster";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LEVEL_STYLE } from "./ui/Toaster";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
@@ -132,22 +133,24 @@ export default function NotificationsPanel() {
|
||||
</div>
|
||||
{hasAny && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={markAll}
|
||||
disabled={!hasUnread || readAllMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<CheckCheck className="w-4 h-4" />}
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
{t("inbox.markAllRead")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={clearEverything}
|
||||
disabled={clearMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("inbox.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,43 +308,45 @@ function NotificationRow({
|
||||
</ul>
|
||||
)}
|
||||
{isScheduler && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenScheduler}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Activity className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
{t("inbox.openScheduler")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{isDownloadDone && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenDownloads}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("inbox.openDownloads")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!n.read && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRead}
|
||||
title={t("inbox.markRead")}
|
||||
aria-label={t("inbox.markRead")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDismiss}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -396,47 +401,52 @@ function ClientActivityRow({
|
||||
</div>
|
||||
{hidden ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Eye className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{t("notifications.unhide")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : watched ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("notifications.unwatch")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : subscribed ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFocusChannel(subscribed.channelName)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Tv className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Tv className="w-3.5 h-3.5" />
|
||||
{t("notifications.openInManager")}
|
||||
</button>
|
||||
</Button>
|
||||
<a
|
||||
href={channelYouTubeUrl(subscribed.channelId)}
|
||||
target="_blank"
|
||||
@@ -448,36 +458,39 @@ function ClientActivityRow({
|
||||
</a>
|
||||
</div>
|
||||
) : access ? (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1"
|
||||
onClick={() => onReviewAccess()}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
|
||||
icon={<UserPlus className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("common.accessRequestsReview")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
n.action &&
|
||||
!n.dismissed && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-1 text-sm"
|
||||
onClick={() => {
|
||||
n.action!.onClick();
|
||||
dismissClient(n.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{n.action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onRemove}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { qk } from "../lib/queryKeys";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import Overlay from "./Overlay";
|
||||
import { Button } from "./ui/Button";
|
||||
import Overlay from "./ui/Overlay";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||
@@ -159,12 +160,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Button variant="ghost" size="md" onClick={close}>
|
||||
{t("onboarding.read.skip")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
||||
</button>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
{t("onboarding.write.keepReadOnly")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
|
||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user